Итак, начнём.
При внедрении DevOps-процесса в компании одним из возможных вариантов хранилища артефактов сборки может стать rpm-репозиторий. По существу — это просто веб-сервер, раздающий определённым образом организованное содержимое. Есть, конечно, коммерческие варианты maven-репозиториев, которые имеют плагины для поддержки rpm, но мы же не ищем лёгких путей?
Задача
Написать сервис, который будет принимать готовые rpm-пакеты по протоколу HTTP, парсить их метаданные, раскладывать файлы пакетов по каталогам в соответствии с внутренней структурой репозитория и обновлять метаданные репозитория после обработки очередного пакета. Что из этого получилось — описано под катом.
Анализ
В моей голове задача почти мгновенно распалась на несколько частей: первая — принимающая, которая должна принять rpm-пакет по HTTP; вторая — обрабатывающая, которая должна принятый RPM-пакет обработать. Ну и где-то ещё должен быть веб-сервер, который будет раздавать содержимое репозитория.
Принимающая часть
Ввиду того, что с Nginx я знаком давно, выбор веб-сервера для приёма rpm-пакетов и раздачи содержимого репозитория даже не стоял — только Nginx. Приняв это как данность, я нашёл в документации нужные опции и написал
location /upload {
proxy_http_version 1.1;
proxy_pass http://127.0.0.1:5000;
proxy_pass_request_body off;
proxy_set_header X-Package-Name $request_body_file;
client_body_in_file_only on;
client_body_temp_path /tmp/rpms;
client_max_body_size 128m;
}
Результат данной конфигурации — при приёме файла Nginx сохранит его в заданный каталог и сообщит исходное имя в отдельном заголовке.
Для полноты картины — вторая крохотная
location /repo {
alias /srv/repo/storage/;
autoindex on;
}
Итак, у нас есть первая часть, которая умеет принимать файлы и отдавать их.
Обрабатывающая часть
Обрабатывающая часть напиcана на Python без особых премудростей и выглядит
#!/usr/bin/env python
import argparse
import collections
import pprint
import shutil
import subprocess
import threading
import os
import re
import yaml
from flask import Flask, request
from pyrpm import rpmdefs
from pyrpm.rpm import RPM
# Сервис для поддержания репозиториев (С) Sergey Pechenko, 2017
# Лицензия - GPL v2.0. Никаких дополнительных гарантий или прав не предоставляется.
# Для лицензирования использования кода в коммерческом продукте свяжитесь с автором.
class LoggingMiddleware(object):
# Вспомогательный класс для логирования запросов и отладки
def __init__(self, app):
self._app = app
def __call__(self, environ, resp):
errorlog = environ['wsgi.errors']
pprint.pprint(('REQUEST', environ), stream=errorlog)
def log_response(status, headers, *args):
pprint.pprint(('RESPONSE', status, headers), stream=errorlog)
return resp(status, headers, *args)
return self._app(environ, log_response)
def parse_package_info(rpm):
# Обработка метаданных пакета
os_name_rel = rpm[rpmdefs.RPMTAG_RELEASE]
os_data = re.search('^(\d+)\.(\w+)(\d+)$', os_name_rel)
package = {
'filename': "%s-%s-%s.%s.rpm" % (rpm[rpmdefs.RPMTAG_NAME],
rpm[rpmdefs.RPMTAG_VERSION],
rpm[rpmdefs.RPMTAG_RELEASE],
rpm[rpmdefs.RPMTAG_ARCH]),
'os_abbr': os_data.group(2),
'os_release': os_data.group(3),
'os_arch': rpm[rpmdefs.RPMTAG_ARCH]
}
return package
# Объект приложения и его настройки
app = Flask(__name__)
settings = {}
# Тестовый обработчик - пригодится в начале настройки
@app.route('/')
def hello_world():
return 'Hello from repo!'
# Обработчик конкретного маршрута в URL
@app.route('/upload', methods=['PUT'])
def upload():
# Ответ по умолчанию
status = 503
headers = []
# Этот нестандартный заголовок мы добавили в конфигурацию Nginx ранее
curr_package = request.headers.get('X-Package-Name')
rpm = RPM(file(unicode(curr_package)))
rpm_data = parse_package_info(rpm)
try:
new_req_queue_element = '%s/%s' % (rpm_data['os_release'], rpm_data['os_arch'])
dest_dirname = '%s/%s/Packages' % (
app.settings['repo']['top_dir'],
new_req_queue_element)
# Перемещаем файл в нужный каталог
shutil.move(curr_package, dest_dirname)
src_filename = '%s/%s' % (dest_dirname, os.path.basename(curr_package))
dest_filename = '%s/%s' % (dest_dirname, rpm_data['filename'])
# Переименовываем файл
shutil.move(src_filename, dest_filename)
# Готовим ответ, который получит загружавший клиент
response = 'OK - Accessible as %s' % dest_filename
status = 200
if new_req_queue_element not in req_queue:
# Кладём запрос на обработку этого пакета в очередь
req_queue.append(new_req_queue_element)
event_timeout.set()
event_request.set()
except BaseException as E:
response = E.message
return response, status, headers
def update_func(evt_upd, evt_exit):
# Ждёт события, затем запускает обновление метаданных
while not evt_exit.is_set():
if evt_upd.wait():
# Выбираем следующий доступный запрос из очереди
curr_elem = req_queue.popleft()
p = subprocess.Popen([app.settings['index_updater']['executable'],
app.settings['index_updater']['cmdline'],
'%s/%s' % (app.settings['repo']['top_dir'], curr_elem)],
shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
res_stdout, res_stderr = p.communicate(None)
pprint.pprint(res_stdout)
pprint.pprint(res_stderr)
# Сбрасываем событие обновления
evt_upd.clear()
return
def update_enable_func(evt_req, evt_tmout, evt_upd, evt_exit):
while not evt_exit.is_set():
# Ожидаем запрос
evt_req.wait()
# OK, дождались
# Теперь выдерживаем 30 секунд, а если в это время пришёл запрос....
while evt_tmout.wait(30) and (not evt_exit.is_set()):
evt_tmout.clear()
if evt_exit.is_set():
break
evt_upd.set()
evt_tmout.clear()
evt_req.clear()
return
def parse_command_line():
# Разбор агрументов командной строки
parser = argparse.ArgumentParser(description='This is a repository update helper')
parser.prog_name = 'repo_helper'
parser.add_argument('-c', '--conf', action='store', default='%.yml' % prog_name, type='file', required='false',
help='Name of the config file', dest='configfile')
parser.epilog('This is an example of Nginx configuration: location /repo { alias /srv/repo/storage/; autoindex on; } location /upload { client_body_in_file_only on; client_body_temp_path /tmp/rpms; client_max_body_size 128m; proxy_http_version 1.1; proxy_pass http://localhost:5000; proxy_pass_request_body off; proxy_set_header X-Package-Name $request_body_file; }')
parser.parse_args()
return parser
def load_config(fn):
with open(fn, 'r') as f:
config = yaml.safe_load(f)
return config
def load_hardcoded_defaults():
# Прибитые гвоздями настройки "по умолчанию"
config = {
'index_updater': {
'executable': '/bin/createrepo',
'cmdline': '--update'
},
'repo': {
'top_dir': '/srv/repo/storage'
},
'server': {
'address': '127.0.0.1',
'port': '5000',
'prefix_url': 'upload',
'upload_header': ''
},
'log': {
'name': 'syslog',
'level': 'INFO'
}
}
return config
if __name__ == '__main__':
try:
cli_args = parse_command_line()
settings = load_config(cli_args['configfile'])
except BaseException as E:
settings = load_hardcoded_defaults()
req_queue = collections.deque()
# Application-level specific stuff
# Exit flag
exit_flag = False
# Событие, сигналящее о пришедшем запросе
event_request = threading.Event()
# Событие, сигналящее об окончании задержки
event_timeout = threading.Event()
# Событие, сигналящее о запуске обновления метаданных
event_update = threading.Event()
# Событие, сигналящее о завершении вспомогательных потоков
event_exit = threading.Event()
# Готовим начальное состояние событий
event_request.clear()
event_timeout.clear()
event_update.clear()
# Поток, который запускает обновление метаданных репозитория
update_thread = threading.Thread(name='update_worker', target=update_func, args=(event_update, event_exit))
update_thread.start()
# Поток, отсчитывающий время задержки, и начинающий отсчёт сначала, если задержка прервана
# Если задержка прервана - начинаем отсчёт сначала
delay_thread = threading.Thread(name='delay_worker', target=update_enable_func,
args=(event_request, event_timeout, event_update, event_exit))
delay_thread.start()
# Его Величество Приложение
app.wsgi_app = LoggingMiddleware(app.wsgi_app)
app.run(host=settings['server']['address'], port=settings['server']['port'])
# Это событие заставит оба дополнительных потока завершиться
event_exit.clear()
Важный и, скорее всего, непонятный с первого взгляда момент — зачем же здесь нужны потоки, события и очередь.
Они нужны для передачи данных между асинхронными процессами. Вот смотрите, ведь HTTP-клиент не обязан ждать какого-то разрешения, чтобы загрузить пакет? Правильно, он может начать загрузку в любой момент. Соответственно, в основном потоке приложения мы должны сообщить клиенту об успешности/неуспешности загрузки, и, если загрузка удалась, передать данные через очередь другому потоку, который выполняет вычитывание метаданных пакета, а затем перемещение его в файловой системе. При этом отдельный поток следит, прошло ли 30 секунд с момента загрузки последнего пакета, или нет. Если прошло — метаданные репозитория будут обновлены. Если же время ещё не вышло, а уже пришёл следющий запрос — сбрасываем и перезапускаем таймер. Таким образом, всякая загрузка пакета будет отодвигать обновление метаданных на 30 секунд.
Как пользоваться
Сначала нужно
appdirs==1.4.3
click==6.7
Flask==0.12.1
itsdangerous==0.24
Jinja2==2.9.6
MarkupSafe==1.0
packaging==16.8
pyparsing==2.2.0
pyrpm==0.3
PyYAML==3.12
six==1.10.0
uWSGI==2.0.15
Werkzeug==0.12.1
К сожалению, я не могу гарантировать, что это минимально возможный список — команда pip freeze просто берёт список доступных пакетов Python и механически переносит его в файл, не рассматривая, используется ли конкретный пакет в конкретном проекте или нет.
Затем нужно установить пакеты с nginx и c createrepo:
yum install -y nginx createrepo
Запуск проекта выглядит вот так:
nohup python app.py
После того, как всё будет запущено, можно пробовать загрузить rpm-пакет в репозиторий вот такой командой:
curl http://hostname.example.com/upload -T <packagename-1.0.rpm>
Я понимаю, что описанный сервис далёк от совершенства и являет собой скорее прототип, нежели полноценное приложение, но, с другой стороны, он может быть легко дополнен/расширен.
Для удобства желающих код выложен на GitHub. Предложения по дополнению сервиса, а ещё лучше — pull-request'ы горячо приветствуются!
Надеюсь, этот прототип окажется кому-то полезным. Спасибо за внимание!
Ну и для тех, кому очень нужно, небольшой сниппет для укрощения SELinux:
#!/bin/bash
semanage fcontext -a -t httpd_sys_rw_content_t "/srv/repo/storage(/.*)?"
restorecon -R -v /srv/repo/storage
setsebool -P httpd_can_network_connect 1
MMik
Интересно. Это такой упрощённый Pulp у вас получился (REST тоже поддерживается):
Или repositor.io:Или PackageCloud локальный.
Вопрос вдогонку. Лицензия GPL v2.0, или необходимо связываться с вами для лицензирования в коммерческом продукте? Что подразумевается вами под коммерческим продуктом?
MMik
И чтобы второй раз с места не вставать, Сергей, сколько стоит лицензия на ваш код для использования в коммерческом продукте? Спасибо.
tnt4brain Автор
Хорошо, вы связались. Как автор, сообщаю — данную кучку кода для использования в коммерческих продуктах не лицензирую.
tnt4brain Автор
Прошу извинить, не смог понять из комментария, хотите ли вы уточнить детали или просто троллите — выражения лица не вижу, интонаций не слышу, смайлики отсутствуют.
Мой посыл так же лёгок в понимании и прост, как гвоздодёр из титанового сплава: хотите этот код развивать и поддерживать как Open Source Software — пожалуйста. Хотите зарабатывать на нём деньги — вам придётся делать это вместе со мной, только и всего.
MMik
Не троллю. Просто не нужен этот код дома или в некоммерческих проектах — это во-первых. Во-вторых, GPL 2.0 прямо запрещает налагать дополнительные ограничения, как вы это пытаетесь сделать:
Нет никакого смысла пушить вам коммиты с фиксами и фичами, если использовать ваш код можно только в некоммерческих проектах.tnt4brain Автор
Забавно. Вы — третий человек по счёту человек, который пытается уличить меня в нарушении GPL. Правда, предыдущие два сочли более уместным написать в личку, но воля ваша — обсудим вопрос здесь.
Предлагаю вам проверить по любому доступному словарю слово "redistribute", а затем привести в следующем комментарии ваш перевод этого слова и ваше понимание того, каким образом процитированный абзац применим ко мне как к автору данного кода.
Автор имеет право налагать на код любые лицензионные ограничения, какие он сочтёт необходимым (хоть запускать код только в полнолуние, ночью, и на перекрёстке трёх дорог).
tuupic
Не совсем понял, зачем сложность с тредами. Почему не просто gunicorn с gevent воркером?
tnt4brain Автор
В большом репозитории операция обновления метаданных занимает отнюдь не нулевое время, поэтому я изначально решил, что запускать обновление по событию «загрузка» — не вариант: элементарная ситуация «гонок», когда обновление после предыдущей загрузки ещё идёт, а уже в наличии следующая загрузка, напрочь ломает весь концепт.
Закладываться на то, что эта ситуация никогда не возникнет, я счёл бессмысленным — своими глазами видел, как при почти одновременных загрузках пакетов в один из коммерческих репозиториев (правда, в бесплатной версии) файл с метаданными просто обнуляется в размере. Подобная потеря данных может быть одним из признаков «гонок», то есть некорректной обработки многопоточного доступа. Отсюда и необходимость обработки подобной ситуации.
Я не берусь утверждать, что мой подход — единственно правильный, но факт использования очереди «под капотом» того же Pulp может быть косвенным подтверждением моей позиции.
tuupic
Залился файл, потрогали touch`ем файл-флаг. Раз в N времени по крону скрипт пускать, который сравнит время модификации файл-флага, и времени последнего запуска и запустит createrepo если первое новее
tnt4brain Автор
Вполне возможно, что этот концепт работоспособен, но эксплуатация этого варианта точно не для меня — слишком много движущихся частей, слишком легко чему-то пойти не так.
chemistmail
rsync сервер для заливки
Правило в unit в systemd для апдейта репы
Nginx для раздачи репы
И не нужно писать код
akamensky
У нас примерно тоже самое только через Jenkins:
Jenkins собирает пакет после merge в репозитории
Он же подписывает этот пакет (да-да в статье не написано, но это считается дурным тоном не подписывать rpm пакеты)
Он же заливает пакеты в репозиторий (через sshfs)
Он же обновляет мета-данные репозитория и подписывает их
И все настраивается красиво через WebUI. Непонятно зачем такие сложности с написанием своего велосипеда на Python
tnt4brain Автор
Откровенно говоря, у меня для сборки пакетов тоже используется Jenkins, но мой опыт эксплуатации говорит о том, что делать из него «швейцарский нож» себе дороже. В моей ситуации работа Jenkins заканчивается на этапе отправки пакета на веб-сервер.
Насчёт подписи — да, подписи — это хорошо, но я не считаю для себя возможным обучать читателей правилам хорошего тона при сборке rpm-пакетов.
Кстати, чисто технически интересно, как в описанном вами варианте решён вопрос с гонками при одновременной заливке пакетов?
akamensky
Так как всем управляет Jenkins, то у нас делается в два этапа:
— первая задача собирает пакет по триггеру из репозитория и кладет пакет в промежуточную директорию, в конце она вызывает задачу номер 2
— вторая задача смотрит что лежит во временной директории, подписывает, кладет в WebRoot, обновляет мета-данные и подписывает их
При этом у второй задачи в настройках указано — ждать если есть другие задачи в очереди, т.е. если у меня одновременно два (или больше) пакета собирается, то вторая задача не начнет выполнятся пока не появится окно между сборками. Чтобы особо долго не ждать можно выставить в настройках чтобы вторая задача ждала не дольше определенного времени. Т.е. я сначала аггрегирую пакеты которые уже готовы и вторая задача их все вместе зальет.
Еще в Jenkins можно отключить параллельное исполнение одной и той же задачи, т.е. даже если есть свободные воркеры, повторный вызов просто встанет в очередь.
acmnu
Для тех, кто не хочет все это поддерживать есть ещё вариант купить Artifactory от Jrog. Приемущество этого продукта в том, что там из коробки порядка 20 поддерживаемых типов реп, что может быть удобно для задач DevOps.
tnt4brain Автор
Да, есть такой продукт, но лично для меня существуют несколько важных моментов:
На мой вкус, сравнивать работающий концепт, сделанный за выходные, и коммерческий продукт как-то нелогично, не находите? Я никогда не видел, чтобы велосипеды и суперкары участвовали в одних и тех же гонках.
А по поводу поддержки — сервис работает в существующем виде порядка двух месяцев, и мне его даже перезапускать не пришлось ни разу. Единственный озадачивший меня эпизод — когда Jenkins из-за ошибки в скриптах удалил всё в /tmp.
acmnu
От чего же. На мой взгляд это чертовски важный концептуальный вопрос: надо ли делать велосипеды, когда есть готовое решение. На мой взгляд, если мы говорим о DevOps — нет. Ценность в DevOps представляет логика сборки и поставки продукта. В эту логику надо инвестировать время и деньги и следить за тем какие она дает результаты. Все остальное лишь инструментарий и заниматься его разработкой без крайней необходимости не следует.
amarao
Не выглядит как devops. Какой app.py? Где приложение в pypi? Где setup.py, где собственная rpm'ка? Плохо.
В мире дебиана всё лучше —
aptly
наше всё, и оно отлично интегрируется с jenkins-debian-glue, gbp buildpackage, dpkg-buildpackage и всей остальной инфраструктурой сборки пакетов.А вот у rpm'щиков это как-то всё похуже, как я вижу.
rino906
Отнюдь, для простых случаев есть pulp, для devops есть jenkins+koji
tnt4brain Автор
А никто и не обещал, собственно говоря. В начале статьи обозначена задача, ниже приведён вариант её решения. Кому этого достаточно — будут пользоваться, кому мало — не будут. На мой взгляд, важно, чтобы у людей просто был выбор — ставить и поддерживать что-то огромное типа Pulp, либо закупать платные решения (кстати, всё равно потом их кто-то будет ставить и поддерживать), либо вот такие простые штуки использовать. Тут каждый сам для себя должен решить, что ему важнее.
amarao
Я говорю, что ваш вариант решения плох, потому что вы недоделали программу до человеческого уровня. Неужели setup.py с энтрипоинтами это так уж сложно?
worsediablo
А чем createrepo_c не подошел?
tnt4brain Автор
createrepo_c не решает все задачи, вынесенные в начало статьи.
rakhinskiy
А никто не подскажет, есть ли свободные решения которые умеют делать virtual repo который объединяет в себе несколько других. В документации artifactory есть такое понятие Artifactory RPM Repositories. Было бы удобно централизовано подключить все нужные репы (epel/remi/zabbix/freeswitch/...), добавить туда свой локальный репозиторий, и представить в виде одного репозитория.
tnt4brain Автор
Да, у Artifactory такое есть.
Из бесплатных решений — у Pulp есть что-то очень похожее на «content sources».
chelaxe
Делал подобное. У меня это была связь apache и vsftpd. Без авторизации в web части просто доступ к файлам. С помощью mod_autoindex и кучи плюшек (jquery, bootstrap, font awesome, backbone и showdown для отображения markdown файлов) сделан интерфейс, при этом отсутствие javascript не приводит к поломке отображения файлов (привет noscript). Отдельная страничка для добавления файлов в репозиторий (ajax, multiple files — привет ie9) требует авторизации. На сервисе обслуживает PHP скрипт (сразу выполнил в едином файле и добавления файлов и обход по крону кучки для добавления, ну и запуск createrepo.). vsftpd без авторизации просто доступ к файлам, а с авторизацией доступ к директории загрузки (директория одна для всех и права запрещают просмотр что приводит к тому, что не видно ее содержимое но можно загружать файлы). При запуске php скрипта из крона проходит парсинг логов vsftpd (apache не парсится там сразу записывается в бд факт добавления файлов при обработке запросов из браузера) и записываем кто какие файлы добавил. Для разбора rpm файлов в php использовал библиотеку rpmreader.
Из идей которые еще не сделаны:
— уход от крона в пользу механизма inotify
— создание репозитория для pip python
— возможность управлять добавленными пакетами
— красивая статистика с применением d3 библиотеки для графиков
tnt4brain Автор
Интересный комментарий очень практической направленности. Над веб-интерфейсом я, если честно, задумывался, но крайне ненадолго — нашёл, как включить autoindex, и на этом успокоился. Справедливости ради хочу заметить, что для полноценного решения веб-интерфейс действительно необходим.
Правильно ли я понял, что часть загружающая и часть обрабатывающая впрямую не взаимодействуют?
chelaxe
Не совсем. Все это, и загрузка (html страница отдает по ajax php скрипту с пред обработкой rpm пакета) и обработка последующая (тут полная обработка файлов полученных от ftp) выполнена в едином php скрипте, но логически разделена. Т.е. загружаем файлы, сразу они не появляются, а раз в час только выгружаются после обработки. Если бы исключить ftp, то можно было бы сразу грузить и обрабатывать. Inotify очень понравился, но пока руки не доходят.