Итак, начнём.


При внедрении DevOps-процесса в компании одним из возможных вариантов хранилища артефактов сборки может стать rpm-репозиторий. По существу — это просто веб-сервер, раздающий определённым образом организованное содержимое. Есть, конечно, коммерческие варианты maven-репозиториев, которые имеют плагины для поддержки rpm, но мы же не ищем лёгких путей?


image


Задача


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


Анализ


В моей голове задача почти мгновенно распалась на несколько частей: первая — принимающая, которая должна принять rpm-пакет по HTTP; вторая — обрабатывающая, которая должна принятый RPM-пакет обработать. Ну и где-то ещё должен быть веб-сервер, который будет раздавать содержимое репозитория.


Принимающая часть


Ввиду того, что с Nginx я знаком давно, выбор веб-сервера для приёма rpm-пакетов и раздачи содержимого репозитория даже не стоял — только Nginx. Приняв это как данность, я нашёл в документации нужные опции и написал


Часть конфигурации 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 секунд.


Как пользоваться


Сначала нужно


Установить пакеты Python по списку:

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'ы горячо приветствуются!


Надеюсь, этот прототип окажется кому-то полезным. Спасибо за внимание!


P.S.

Ну и для тех, кому очень нужно, небольшой сниппет для укрощения 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

Комментарии (28)


  1. MMik
    15.09.2017 05:09
    +1

    Интересно. Это такой упрощённый Pulp у вас получился (REST тоже поддерживается):

    pulp-admin rpm repo create --repo-id myrepo
    pulp-admin rpm repo uploads rpm --repo-id myrepo --file ./lalala-1.2-3.noarch.rpm
    pulp-admin rpm repo publish run --repo-id myrepo
    Или repositor.io:
    repositorio --repo=myrepo --init
    repositorio --repo=myrepo --add-file=lalala-1.2-3.noarch.rpm
    Или PackageCloud локальный.

    Вопрос вдогонку. Лицензия GPL v2.0, или необходимо связываться с вами для лицензирования в коммерческом продукте? Что подразумевается вами под коммерческим продуктом?
    # Сервис для поддержания репозиториев (С) Sergey Pechenko, 2017
    # Лицензия — GPL v2.0. Никаких дополнительных гарантий или прав не предоставляется.
    # Для лицензирования использования кода в коммерческом продукте свяжитесь с автором.


    1. MMik
      15.09.2017 05:20

      И чтобы второй раз с места не вставать, Сергей, сколько стоит лицензия на ваш код для использования в коммерческом продукте? Спасибо.


    1. tnt4brain Автор
      15.09.2017 10:48
      +1

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


    1. tnt4brain Автор
      15.09.2017 10:50
      -2

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

      Мой посыл так же лёгок в понимании и прост, как гвоздодёр из титанового сплава: хотите этот код развивать и поддерживать как Open Source Software — пожалуйста. Хотите зарабатывать на нём деньги — вам придётся делать это вместе со мной, только и всего.


      1. MMik
        18.09.2017 11:54
        -1

        Не троллю. Просто не нужен этот код дома или в некоммерческих проектах — это во-первых. Во-вторых, GPL 2.0 прямо запрещает налагать дополнительные ограничения, как вы это пытаетесь сделать:

        6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein.
        Нет никакого смысла пушить вам коммиты с фиксами и фичами, если использовать ваш код можно только в некоммерческих проектах.


        1. tnt4brain Автор
          18.09.2017 14:17

          Забавно. Вы — третий человек по счёту человек, который пытается уличить меня в нарушении GPL. Правда, предыдущие два сочли более уместным написать в личку, но воля ваша — обсудим вопрос здесь.


          Предлагаю вам проверить по любому доступному словарю слово "redistribute", а затем привести в следующем комментарии ваш перевод этого слова и ваше понимание того, каким образом процитированный абзац применим ко мне как к автору данного кода.


          Правильный ответ:

          Автор имеет право налагать на код любые лицензионные ограничения, какие он сочтёт необходимым (хоть запускать код только в полнолуние, ночью, и на перекрёстке трёх дорог).


  1. tuupic
    15.09.2017 07:36

    Не совсем понял, зачем сложность с тредами. Почему не просто gunicorn с gevent воркером?


    1. tnt4brain Автор
      15.09.2017 10:38
      +1

      В большом репозитории операция обновления метаданных занимает отнюдь не нулевое время, поэтому я изначально решил, что запускать обновление по событию «загрузка» — не вариант: элементарная ситуация «гонок», когда обновление после предыдущей загрузки ещё идёт, а уже в наличии следующая загрузка, напрочь ломает весь концепт.
      Закладываться на то, что эта ситуация никогда не возникнет, я счёл бессмысленным — своими глазами видел, как при почти одновременных загрузках пакетов в один из коммерческих репозиториев (правда, в бесплатной версии) файл с метаданными просто обнуляется в размере. Подобная потеря данных может быть одним из признаков «гонок», то есть некорректной обработки многопоточного доступа. Отсюда и необходимость обработки подобной ситуации.
      Я не берусь утверждать, что мой подход — единственно правильный, но факт использования очереди «под капотом» того же Pulp может быть косвенным подтверждением моей позиции.


      1. tuupic
        15.09.2017 10:45

        Залился файл, потрогали touch`ем файл-флаг. Раз в N времени по крону скрипт пускать, который сравнит время модификации файл-флага, и времени последнего запуска и запустит createrepo если первое новее


        1. tnt4brain Автор
          15.09.2017 10:55
          +1

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


  1. chemistmail
    15.09.2017 08:24

    rsync сервер для заливки
    Правило в unit в systemd для апдейта репы
    Nginx для раздачи репы
    И не нужно писать код


    1. akamensky
      15.09.2017 10:36

      У нас примерно тоже самое только через Jenkins:
      Jenkins собирает пакет после merge в репозитории
      Он же подписывает этот пакет (да-да в статье не написано, но это считается дурным тоном не подписывать rpm пакеты)
      Он же заливает пакеты в репозиторий (через sshfs)
      Он же обновляет мета-данные репозитория и подписывает их

      И все настраивается красиво через WebUI. Непонятно зачем такие сложности с написанием своего велосипеда на Python


      1. tnt4brain Автор
        15.09.2017 11:06
        +1

        Откровенно говоря, у меня для сборки пакетов тоже используется Jenkins, но мой опыт эксплуатации говорит о том, что делать из него «швейцарский нож» себе дороже. В моей ситуации работа Jenkins заканчивается на этапе отправки пакета на веб-сервер.
        Насчёт подписи — да, подписи — это хорошо, но я не считаю для себя возможным обучать читателей правилам хорошего тона при сборке rpm-пакетов.
        Кстати, чисто технически интересно, как в описанном вами варианте решён вопрос с гонками при одновременной заливке пакетов?


        1. akamensky
          15.09.2017 11:24

          Так как всем управляет Jenkins, то у нас делается в два этапа:
          — первая задача собирает пакет по триггеру из репозитория и кладет пакет в промежуточную директорию, в конце она вызывает задачу номер 2
          — вторая задача смотрит что лежит во временной директории, подписывает, кладет в WebRoot, обновляет мета-данные и подписывает их

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

          Еще в Jenkins можно отключить параллельное исполнение одной и той же задачи, т.е. даже если есть свободные воркеры, повторный вызов просто встанет в очередь.


  1. acmnu
    15.09.2017 10:35

    Для тех, кто не хочет все это поддерживать есть ещё вариант купить Artifactory от Jrog. Приемущество этого продукта в том, что там из коробки порядка 20 поддерживаемых типов реп, что может быть удобно для задач DevOps.


    1. tnt4brain Автор
      15.09.2017 11:22
      +1

      Да, есть такой продукт, но лично для меня существуют несколько важных моментов:

      1. изучение этого продукта не входило в мои задачи;
      2. мне требуется только три типа репозиториев из большого перечня возможностей продукта;
      3. а с точки зрения изначально решаемой задачи лично я не вижу смысла покупать лицензию на Artifactory, чтобы получить результат, аналогичный результату «проекта выходного дня»

      На мой вкус, сравнивать работающий концепт, сделанный за выходные, и коммерческий продукт как-то нелогично, не находите? Я никогда не видел, чтобы велосипеды и суперкары участвовали в одних и тех же гонках.
      А по поводу поддержки — сервис работает в существующем виде порядка двух месяцев, и мне его даже перезапускать не пришлось ни разу. Единственный озадачивший меня эпизод — когда Jenkins из-за ошибки в скриптах удалил всё в /tmp.


      1. acmnu
        15.09.2017 13:24
        -2

        На мой вкус, сравнивать работающий концепт, сделанный за выходные, и коммерческий продукт как-то нелогично, не находите? Я никогда не видел, чтобы велосипеды и суперкары участвовали в одних и тех же гонках.

        От чего же. На мой взгляд это чертовски важный концептуальный вопрос: надо ли делать велосипеды, когда есть готовое решение. На мой взгляд, если мы говорим о DevOps — нет. Ценность в DevOps представляет логика сборки и поставки продукта. В эту логику надо инвестировать время и деньги и следить за тем какие она дает результаты. Все остальное лишь инструментарий и заниматься его разработкой без крайней необходимости не следует.


  1. amarao
    15.09.2017 11:49
    +2

    Не выглядит как devops. Какой app.py? Где приложение в pypi? Где setup.py, где собственная rpm'ка? Плохо.

    В мире дебиана всё лучше — aptly наше всё, и оно отлично интегрируется с jenkins-debian-glue, gbp buildpackage, dpkg-buildpackage и всей остальной инфраструктурой сборки пакетов.

    А вот у rpm'щиков это как-то всё похуже, как я вижу.


    1. rino906
      15.09.2017 18:33

      А вот у rpm'щиков это как-то всё похуже, как я вижу.

      Отнюдь, для простых случаев есть pulp, для devops есть jenkins+koji


    1. tnt4brain Автор
      15.09.2017 21:32
      +1

      А никто и не обещал, собственно говоря. В начале статьи обозначена задача, ниже приведён вариант её решения. Кому этого достаточно — будут пользоваться, кому мало — не будут. На мой взгляд, важно, чтобы у людей просто был выбор — ставить и поддерживать что-то огромное типа Pulp, либо закупать платные решения (кстати, всё равно потом их кто-то будет ставить и поддерживать), либо вот такие простые штуки использовать. Тут каждый сам для себя должен решить, что ему важнее.


      1. amarao
        15.09.2017 23:28

        Я говорю, что ваш вариант решения плох, потому что вы недоделали программу до человеческого уровня. Неужели setup.py с энтрипоинтами это так уж сложно?


  1. worsediablo
    15.09.2017 21:12

    А чем createrepo_c не подошел?


    1. tnt4brain Автор
      15.09.2017 21:34
      +1

      createrepo_c не решает все задачи, вынесенные в начало статьи.


  1. rakhinskiy
    16.09.2017 01:22

    А никто не подскажет, есть ли свободные решения которые умеют делать virtual repo который объединяет в себе несколько других. В документации artifactory есть такое понятие Artifactory RPM Repositories. Было бы удобно централизовано подключить все нужные репы (epel/remi/zabbix/freeswitch/...), добавить туда свой локальный репозиторий, и представить в виде одного репозитория.


    1. tnt4brain Автор
      16.09.2017 01:32
      +1

      Да, у Artifactory такое есть.

      Из бесплатных решений — у Pulp есть что-то очень похожее на «content sources».


  1. chelaxe
    16.09.2017 20:22
    +1

    Делал подобное. У меня это была связь 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 библиотеки для графиков


    1. tnt4brain Автор
      17.09.2017 19:35
      +1

      Интересный комментарий очень практической направленности. Над веб-интерфейсом я, если честно, задумывался, но крайне ненадолго — нашёл, как включить autoindex, и на этом успокоился. Справедливости ради хочу заметить, что для полноценного решения веб-интерфейс действительно необходим.
      Правильно ли я понял, что часть загружающая и часть обрабатывающая впрямую не взаимодействуют?


      1. chelaxe
        17.09.2017 19:55

        Не совсем. Все это, и загрузка (html страница отдает по ajax php скрипту с пред обработкой rpm пакета) и обработка последующая (тут полная обработка файлов полученных от ftp) выполнена в едином php скрипте, но логически разделена. Т.е. загружаем файлы, сразу они не появляются, а раз в час только выгружаются после обработки. Если бы исключить ftp, то можно было бы сразу грузить и обрабатывать. Inotify очень понравился, но пока руки не доходят.