Хабрахабр

Buildbot: сказ с примерами еще об одной системе непрерывной интеграции


(картинка с официального сайта)

Про него уже было несколько статей на хабре, но, с моей точки зрения, из них не очень понятны преимущества сего инструмента. Buildbot, как несложно догадаться из названия, является инструментом для непрерывной интеграции (continuous integration system, ci). В своей статье я постараюсь восполнить эти недостатки, расскажу про внутренне устройство Buildbot'a и приведу примеры нескольких нестандартных сценариев.
Кроме того, в них почти нет примеров, из-за чего трудно увидеть всю мощь программы.

Общие слова

В настоящее время есть огромное множество систем непрерывной интеграции и когда речь заходит об одной из них, то появляются вполне логичные вопросы в духе «А зачем она нужна, если уже есть и все ей пользуются?». Постараюсь ответить на такой вопрос о Buildbot'e. Часть информации будет дублироваться с уже существующими статьями, часть описана в официальной документации, но это необходимо для последовательности повествования.

Это означает, что для подключения какого-либо проекта к Buildbot'у вы должны сначала написать отдельную программу на питоне с использованием Buildbot-фреймворка, реализующую необходимую вашему проекту функциональность по непрерывной интеграции. Основное отличие от других систем непрерывной интеграции состоит в том, что Buildbot является питоновским фреймворком для написания ci, а не решением из коробки. Такой подход обеспечивает огромную гибкость, позволяя реализовывать хитрые сценарии тестирования, которые невозможны для решений из коробки в силу архитектурных ограничений.

Здесь я отмечу то, что фреймворк весьма лоялен к ресурсам системы. Далее, Buildbot не является сервисом, а потому вы должны честно развернуть его на своей инфраструктуре. Вот, например, сравнение потребления памяти с GoCD (и да, несмотря на название, это система на джаве):
Buildbot:
Это конечно не С или С++, но у своих Java конкурентов питон выигрывает.

GoCD:

Тем не менее, написание сценариев сильно упрощается за счет огромного количества встроенных классов. Самостоятельное развертывание и написание отдельной программы для тестирования могут нагонять тоску при мысли о первоначальной настройке. Как результат, стандартные сценарии для небольших проектов будут не сложнее YML-файлов для какого-нибудь travis-ci. Эти классы покрывают множество стандартных операций, будь то получение изменений из гитхабовского репозитория или сборка проекта CMake'ом. Про развертывание я писать не буду, это подробно освещено в существующих статьях и ничего сложного там тоже нет.

Это идет в разрез с популярным нынче подходом «pipeline as a code», при котором логика тестирования описывается в файле (вроде .travis.yml), лежащим в репозитории вместе с исходным кодом проекта, а ci-сервер лишь считывает этот файл и выполняет то, что в нем сказано. Следующей особенностью Buildbot'a я отмечу то, что по умолчанию логика тестирования реализуется на стороне ci-сервера. Возможности Buildbot-фреймворка позволяют реализовать описанный подход с хранением сценария тестирования в репозитории. Повторюсь, что это лишь поведение по умолчанию. Кроме того, дальше в этой статье я опишу как реализовать что-то похожее на такое поведение самому. Есть даже готовое решение — bb-travis, которое старается взять лучшее от Buildbot'a и travis-ci.

Это может показаться какой-то мелкой ненужной фичей, но для меня это, наоборот, стало одним из главных преимуществ. Buildbot по-умолчанию собирает каждый коммит при пуше. Представьте, что во время разработки вам часто приходится cherry-pick'ать коммиты. Многие популярные решения из коробки (travis-ci, gitlab-ci) такую возможность вообще не предоставляют, работая лишь с последним коммитом на ветке. Конечно, в Buildbot'e сборку только последнего коммита тоже можно сделать, причем делается это установкой всего одного параметра. Будет неприятно взять нерабочий коммит, который не проверялся системой сборки из-за того, что был запушен вместе с пачкой коммитов сверху.

Тем не менее, даже несмотря на такую документацию, некоторые вещи вам может придется смотреть в исходом коде. Фреймворк обладает достаточно хорошей документацией, в которой все подробно описывается от общей архитектуры до руководств по расширению встроенных классов. Из минусов — документация доступна только на английском языке, на русском в сети информации совсем немного. Он полностью открыт под лицензией GPL v2 и легко читаем. Выходят обновления, проект поддерживается множеством разработчиков, поэтому можно говорить о надежности и стабильности. Инструмент появился не вчера, с его помощью собирается сам питон, Wireshark, LLVM и множество других известных проектов.


(главная страница Python Buildbot)

Теормин

Эта часть по сути является вольным переводом главы официальной документации, посвященной архитектуре фреймворка. Здесь показывается полная цепочка действий от получения изменений ci-системой до отправки уведомлений о результате пользователям. Итак, вы внесли изменения в исходный код проекта и отправили их в удаленный репозиторий. То что произойдет далее схематично показано на картинке:

(картинка из официальной документации)

Основных способов здесь два — вебхуки и поллинг, хотя никто не запрещает придумать что-то более изощренное. Первым делом Buildbot должен как-то узнать о том, что в репозитории произошли изменения. Есть много готовых решений, например, GitHubHandler или GitoriusHandler. В первом случае в Buildbot'e за это отвечают классы-наследники BaseHookHandler. Его логика предельно проста: он должен преобразовать HTTP-запрос в список объектов-изменений (changes). Ключевой метод в этих классах — getChanges().

Опять же, есть готовые решения, например GitPoller или HgPoller. Для второго случая нужны классы-наследники PollingChangeSource. Он вызывается с определенной частотой и должен каким-то образом создавать список изменений в репозитории. Ключевой метод — poll(). Если встроенных возможностей не хватает, то достаточно создать свой класс-наследник и перегрузить метод. В случае гита это может быть вызов git fetch и сравнение с предыдущим сохраненным состоянием. Пример использования поллинга:

c['change_source'] = [changes.GitPoller( repourl = 'git@git.example.com:project', project = 'My Project', branches = True, # получаем изменения со всех веток pollInterval = 60 )]

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

c['www']['change_hook_dialects'] = }

Следующим шагом объекты-изменения поступают на вход объектам-планировщикам (schedulers). Примеры встроенных планировщиков: AnyBranchScheduler, NightlyScheduler, ForceScheduler и т.д. Каждый планировщик получает на вход все объекты-изменения, но выбирает только те из них, которые проходят фильтр. Фильтр передается планировщику в конструкторе через аргумент change_filter. На выходе планировщики создают запросы на сборку (build requests). Планировщик выбирает сборщики на основании аргумента builders.

Работает он следующим образом: при получении изменения планировщик не создает сразу новый запрос на сборку, а запускает таймер. У некоторых планировщиков есть хитрый аргумент, именуемый treeStableTimer. Когда таймер заканчивается, планировщик создает всего лишь один запрос на сборку из последнего сохраненного изменения. Если приходят новые изменения, а таймер не истек, то старое изменение заменяется на новое, а таймер обновляется. Пример настройки планировщика:
Таким образом реализуется логика сборки только последнего коммита при пуше.

c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'My Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'My Project'), builderNames = ['My Builder'] )]

Запросы на сборку, как бы странно это ни звучало, поступают на вход сборщикам (builders). Задача сборщика — запустить сборку на доступном «работнике» (worker). Worker — это окружение для сборки, например, stretch64 или ubuntu1804x64. Список worker'ов передается через аргумент workers. Все worker'ы в списке должны быть одинаковыми (т.е. названия-то естественно разные, но окружение внутри одинаковое), поскольку сборщик волен выбрать любой из доступных. Задание нескольких значений здесь служит для балансировки нагрузки, а не для сборки в разных окружениях. Через аргумент factory сборщик получает последовательность шагов для сборки проекта. Про это я подробно распишу далее. Пример настройки сборщика:

c['builders'] = [util.BuilderConfig( name = 'My Builder', workernames = ['stretch32'], factory = factory )]

Итак, проект собрался. Последний шаг Buildbot'a — уведомление о сборке. За это отвечают классы-докладчики (reporters). Классический пример — класс MailNotifier, который отправляет электронное письмо с результатами сборки. Пример подключения MailNotifier:

c['services'] = [reporters.MailNotifier( fromaddr = 'buildbot@example.com', relayhost = 'mail.example.com', smtpPort = 25, extraRecipients = ['devel@example.com'], sendToInterestedUsers = False )]

Что ж, пора переходить к полноценным примерам. Замечу, что сам Buildbot написан с помощью фреймворка Twisted, а потому знакомство с ним существенно облегчит написание и понимание сценариев Buildbot'a. Мальчиком для битья у нас будет проект с названием Pet Project. Пусть он написан на C++, собирается с помощью CMake, а исходный код лежит в git-репозитории. Мы даже не поленились и написали для него тесты, которые запускаются командой ctest. Совсем недавно мы прочитали эту статью и поняли, что хотим применить свежеполученные знания к своему проекту.

Пример первый: чтоб работало

Собственно, конфигурационный файл:

100 строк кода на питоне

from buildbot.plugins import * # shortcut
c = BuildmasterConfig = {} # create workers
c['workers'] = [worker.Worker('stretch32', 'example_password')] # general settings
c['title'] = 'Buildbot: test'
c['titleURL'] = 'https://buildbot.example.com/'
c['buildbotURL'] = 'https://buildbot.example.com/' # setup database
c['db'] = { 'db_url': 'sqlite:///state.sqlite' } # port to communicate with workers
c['protocols'] = { 'pb': { 'port': 9989 } } # make buildbot developers a little bit happier
c['buildbotNetUsageData'] = 'basic' # webserver setup
c['www'] = dict(plugins = dict(waterfall_view={}, console_view={}, grid_view={})) c['www']['authz'] = util.Authz( allowRules = [util.AnyEndpointMatcher(role = 'admins')], roleMatchers = [util.RolesFromUsername(roles = ['admins'], usernames = ['root'])] ) c['www']['auth'] = util.UserPasswordAuth([('root', 'root_password')]) # mail notification
c['services'] = [reporters.MailNotifier( fromaddr = 'buildbot@example.com', relayhost = 'mail.example.com', smtpPort = 25, extraRecipients = ['devel@example.com'], sendToInterestedUsers = False )] c['change_source'] = [changes.GitPoller( repourl = 'git@git.example.com:pet-project', project = 'Pet Project', branches = True, pollInterval = 60 )] c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'Pet Project Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'Pet Project'), builderNames = ['Pet Project Builder'] )] factory = util.BuildFactory()
factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True) )
factory.addStep(steps.ShellSequence( name = 'create builddir', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS, commands = [ util.ShellArg(command = ['rm', '-rf', 'build']), util.ShellArg(command = ['mkdir', 'build']) ]) )
factory.addStep(steps.CMake( workdir = 'build', path = '../sources', haltOnFailure = True) )
factory.addStep(steps.Compile( name = 'build project', workdir = 'build', haltOnFailure = True, warnOnWarnings = True, command = ['make']) )
factory.addStep(steps.ShellCommand( name = 'run tests', workdir = 'build', haltOnFailure = True, command = ['ctest']) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Builder', workernames = ['stretch32'], factory = factory )]

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

Как и в случае с другими классами, есть много готовых решений. Фабрика состоит из шагов (build steps), которые Buildbot должен выполнить для проекта. Как правило, на первом шаге нужно получить актуальное состояние репозитория, и здесь мы не будем делать исключение. Наша фабрика состоит из пяти шагов. Для этого мы используем стандартный класс Git:

Первый шаг

factory = util.BuildFactory()
factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True) )

Далее, нам нужно создать директорию, в которой будет производиться сборка проекта — сделаем полноценный out of source build. Перед этим надо не забыть удалить директорию, если она уже существует. Таким образом, нам нужно выполнить две команды. В этом нам поможет класс ShellSequence:

Второй шаг

factory.addStep(steps.ShellSequence( name = 'create builddir', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS, commands = [ util.ShellArg(command = ['rm', '-rf', 'build']), util.ShellArg(command = ['mkdir', 'build']) ]) )

Теперь необходимо запустить CMake. Для этого логично воспользоваться одним их двух классов — ShellCommand или CMake. Мы воспользуемся последним, но различия здесь минимальны: он является простенькой оберткой над первым классом, позволяя немного удобнее передавать специфичные для CMake аргументы.

Третий шаг

factory.addStep(steps.CMake( workdir = 'build', path = '../sources', haltOnFailure = True) )

Время компилировать проект. Как и в предыдущем случае, можно воспользоваться ShellCommand. Аналогично, есть класс Compile, который является оберткой над ShellCommand. Тем не менее, это уже более хитрая обертка: класс Compile отслеживает предупреждения при компиляции и аккуратно показывает их в отдельном логе. Именно поэтому мы воспользуемся классом Compile:

Четвертый шаг

factory.addStep(steps.Compile( name = 'build project', workdir = 'build', haltOnFailure = True, warnOnWarnings = True, command = ['make']) )

Напоследок, запустим наши тесты. Тут мы воспользуемся упомянутым ранее классом ShellCommand:

Пятый шаг

factory.addStep(steps.ShellCommand( name = 'run tests', workdir = 'build', haltOnFailure = True, command = ['ctest']) )

Пример второй: pipeline as a code

Здесь я покажу, как реализовать бюджетный вариант хранения логики тестирования вместе с исходным кодом проекта, а не в конфигурационном файле ci-сервера. Для этого положим в репозиторий с кодом файл .buildbot, в котором каждая строка состоит из слов, первое из которых интерпретируются как директория для выполнения команды, а оставшиеся как команда со своими аргументами. Для нашего Pet Project файл .buildbot будет выглядеть следующим образом:

Файл .buildbot с командами

rm -rf build
. . mkdir build
build cmake ../sources
build make
build ctest

Теперь нам надо модифицировать конфигурационный файл Buildbot'a. Для анализа .buildbot файла нам придется написать класс собственного шага. Этот шаг будет читать файл .buildbot, после чего для каждой строки добавлять шаг ShellCommand с нужными аргументами. Для динамического добавления шагов мы воспользуемся методом build.addStepsAfterCurrentStep(). Выглядит совсем не страшно:

Класс AnalyseStep

class AnalyseStep(ShellMixin, BuildStep): def __init__(self, workdir, **kwargs): kwargs = self.setupShellMixin(kwargs, prohibitArgs = ['command', 'workdir', 'want_stdout']) BuildStep.__init__(self, **kwargs) self.workdir = workdir @defer.inlineCallbacks def run(self): self.stdio_log = yield self.addLog('stdio') cmd = RemoteShellCommand( command = ['cat', '.buildbot'], workdir = self.workdir, want_stdout = True, want_stderr = True, collectStdout = True ) cmd.useLog(self.stdio_log) yield self.runCommand(cmd) if cmd.didFail(): defer.returnValue(util.FAILURE) results = [] for row in cmd.stdout.splitlines(): lst = row.split() dirname = lst.pop(0) results.append(steps.ShellCommand( name = lst[0], command = lst, workdir = dirname ) ) self.build.addStepsAfterCurrentStep(results) defer.returnValue(util.SUCCESS)

Благодаря такому подходу фабрика для сборщика стала проще и универсальнее:

Фабрика для анализа .buildbot файла

factory = util.BuildFactory()
factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True, mode = 'incremental') )
factory.addStep(AnalyseStep( name = 'Analyse .buildbot file', workdir = 'sources', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) )

Пример третий: worker as a code

Теперь представим, что нам рядом с кодом проекта нужно определять не последовательность команд, а окружение для сборки. По сути, определяем мы worker. .buildbot файл может выглядеть примерно так:

Файл .buildbot с окружением

{
"workers": ["stretch32", "wheezy32"]
}

Конфигурационный файл Buildbot'a в этом случае усложнится, ведь мы хотим, чтобы сборки на разных окружениях были связаны между собой (при ошибке хотя бы в одном окружении, весь коммит считался нерабочим). Решить проблему нам поможет двухуровневость. У нас будет локальный worker, который анализирует .buildbot файл и запускает сборки на нужных worker'ах. Сначала, как и в предыдущем примере, напишем свой шаг для анализа .buildbot файла. Для запуска сборки на конкретном worker'е используется связка из шага Trigger и специального вида планировщиков TriggerableScheduler. Наш шаг стал немного сложнее, но вполне умопостижим:

Класс AnalyseStep

class AnalyseStep(ShellMixin, BuildStep): def __init__(self, workdir, **kwargs): kwargs = self.setupShellMixin(kwargs, prohibitArgs = ['command', 'workdir', 'want_stdout']) BuildStep.__init__(self, **kwargs) self.workdir = workdir @defer.inlineCallbacks def _getWorkerList(self): cmd = RemoteShellCommand( command = ['cat', '.buildbot'], workdir = self.workdir, want_stdout = True, want_stderr = True, collectStdout = True ) cmd.useLog(self.stdio_log) yield self.runCommand(cmd) if cmd.didFail(): defer.returnValue([]) # parse JSON try: payload = json.loads(cmd.stdout) workers = payload.get('workers', []) except json.decoder.JSONDecodeError as e: raise ValueError('Error loading JSON from .buildbot file: {}' .format(str(e))) defer.returnValue(workers) @defer.inlineCallbacks def run(self): self.stdio_log = yield self.addLog('stdio') try: workers = yield self._getWorkerList() except ValueError as e: yield self.stdio_log.addStdout(str(e)) defer.returnValue(util.FAILURE) results = [] for worker in workers: results.append(steps.Trigger( name = 'check on worker "{}"'.format(worker), schedulerNames = ['Pet Project ({}) Scheduler'.format(worker)], waitForFinish = True, haltOnFailure = True, warnOnWarnings = True, updateSourceStamp = False, alwaysUseLatest = False ) ) self.build.addStepsAfterCurrentStep(results) defer.returnValue(util.SUCCESS)

Использовать этот шаг мы будем на локальном worker'е. Обратите внимание, что нашему сборщику «Pet Project Builder» мы установили тег. С помощью него мы можем фильтровать MailNotifier, сообщая ему, что письма нужно отправлять только для определенных сборщиков. Если такой фильтрации не сделать, то при сборке коммита на двух окружениях нам будет приходить три письма.

Общий сборщик

factory = util.BuildFactory()
factory.addStep(steps.Git( repourl = util.Property('repository'), workdir = 'sources', haltOnFailure = True, submodules = True, progress = True, mode = 'incremental') )
factory.addStep(AnalyseStep( name = 'Analyse .buildbot file', workdir = 'sources', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Builder', tags = ['generic_builder'], workernames = ['local'], factory = factory )]

Нам осталось добавить сборщики и те самые Triggerable Schedulers для всех наших реальных worker'ов:

Сборщики в нужных окружениях

for worker in allWorkers: c['schedulers'].append(schedulers.Triggerable( name = 'Pet Project ({}) Scheduler'.format(worker), builderNames = ['Pet Project ({}) Builder'.format(worker)]) ) c['builders'].append(util.BuilderConfig( name = 'Pet Project ({}) Builder'.format(worker), workernames = [worker], factory = specific_factory) )


(страница сборки нашего проекта в двух окружениях)

Пример четвертый: одно письмо на несколько коммитов

Если воспользоваться любым из приведенных выше примеров, то можно заметить одну неприятную особенность. Поскольку на каждый коммит создается одно письмо, то при пуше ветки с 20 новыми коммитами мы получим 20 писем. Избежать этого, как и в предыдущем примере, нам поможет двухуровневость. Также нам понадобится модифицировать класс для получения изменений. Вместо создания множества объектов-изменений мы будем создавать только один такой объект, в свойствах которого передается список всех коммитов. На скорую руку это можно сделать так:

Класс MultiGitHubHandler

class MultiGitHubHandler(GitHubHandler): def getChanges(self, request): new_changes = GitHubHandler.getChanges(self, request) if not new_changes: return ([], 'git') change = new_changes[-1] change['revision'] = '{}..{}'.format( new_changes[0]['revision'], new_changes[-1]['revision']) commits = [c['revision'] for c in new_changes] change['properties']['commits'] = commits return ([change], 'git') c['www']['change_hook_dialects'] = { 'base': { 'custom_class': MultiGitHubHandler } }

Для работы с таким необычным объектом-изменением нам понадобится свой специальный шаг, который динамически создает шаги, собирающие конкретный коммит:

Класс GenerateCommitSteps

class GenerateCommitSteps(BuildStep): def run(self): commits = self.getProperty('commits') results = [] for commit in commits: results.append(steps.Trigger( name = 'Checking commit {}'.format(commit), schedulerNames = ['Pet Project Commits Scheduler'], waitForFinish = True, haltOnFailure = True, warnOnWarnings = True, sourceStamp = { 'branch': util.Property('branch'), 'revision': commit, 'codebase': util.Property('codebase'), 'repository': util.Property('repository'), 'project': util.Property('project') } ) ) self.build.addStepsAfterCurrentStep(results) return util.SUCCESS

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

Общий сборщик для писем

c['schedulers'] = [schedulers.AnyBranchScheduler( name = 'Pet Project Branches Scheduler', treeStableTimer = None, change_filter = util.ChangeFilter(project = 'Pet Project'), builderNames = ['Pet Project Branches Builder'] )] branches_factory = util.BuildFactory()
branches_factory.addStep(GenerateCommitSteps( name = 'Generate commit steps', haltOnFailure = True, hideStepIf = lambda results, s: results == util.SUCCESS) ) c['builders'] = [util.BuilderConfig( name = 'Pet Project Branches Builder', tags = ['branch_builder'], workernames = ['local'], factory = branches_factory )]

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

Общий сборщик для писем

c['schedulers'].append(schedulers.Triggerable( name = 'Pet Project Commits Scheduler', builderNames = ['Pet Project Commits Builder']) ) c['builders'].append(util.BuilderConfig( name = 'Pet Project Commits Builder', workernames = ['stretch32'], factory = specific_factory) )

Финальные слова

Эта статья никоим образом не заменяет чтение официальной документации, поэтому если вы заинтересовались Buildbot'ом, то вашим следующим шагом должно стать ее чтение. Полные версии конфигурационных файлов всех приведенных примеров доступны на гитхабе. Связанные ссылки, из которых и была взята большая часть материалов для статьи:
1. официальная документация;
2. исходный код проекта.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»