В статье решается задача управления описанием сборки для большого количества однотипных приложений. Чтобы в проекте заработал GitLab CI, нужно в репозиторий добавить файл .gitlab-ci.yml. Но что, если в сотне репозиториев это файл с одинаковым содержимым? Даже если разложить его по репозиториям один раз, то как его потом изменять? А что, если одного .gitlab-ci.yml мало для сборки — нужны Dockerfile или Dappfile, разные скрипты и структура YAML-файлов для Helm? Как обновлять их?

С чего начать решение задачи по сборке сотни однотипных приложений? Конечно же, посмотреть, можно ли GitLab CI указать использовать .gitlab-ci.yml из другого репозитория или компоновать .gitlab-ci.yml из файлов в других репозиториях…

В поисках такой возможности сразу всплывают следующие issues:


Видно, что возможность иметь какой-то общий .gitlab-ci.yml интересует сообщество. Решение добавить include секций из файла в другом репозитории кажется очень простым: оно основано на многолетней практике программирования и будет понятно любому. Однако include как концепция хорошо работает в случае с деревом исходников, но в случае нескольких Git-репозиториев в этом решении можно увидеть такие минусы:

  1. В include надо указывать, из какой ветки брать файл для подключения, поэтому сборка не будет воспроизводиться.
  2. В include надо указывать, из какой ветки брать файл для подключения, поэтому нужно кэшировать эффективный .gitlab-ci.yml, хранить его и пересобирать уже на основе него.
  3. В некоторых проектах нужно решить пункт 1, а в некоторых — пункт 2, однако они взаимоисключающи.
  4. Если в подключаемом файле что-то поменялось, то по сути изменяется .gitlab-ci.yml того проекта, который собирается, но истории изменений не будет видно.

Для случая с однотипными приложениями добавляются ещё два минуса:

  1. Проблема с сотней одинаковых .gitlab-ci.yml остаётся.
  2. Проблема с обновлением дополнительных файлов тоже остаётся.

Взгляд под другим углом


Решение с include — это pull-модель, т.е. проект при сборке вытягивает часть конфигурации CI. Если заменить pull на push, то получится так:

  • создаётся проект common-ci-config, в котором хранится общий .gitlab-ci.yml и другие файлы, необходимые для сборки;
  • создаётся пользователь gitlab-ci-distributor, которому даются права на push (Master) в нужные проекты.

Этот вариант работает следующим образом: в проекте common-ci-config хранится общий для сотни других проектов файл .gitlab-ci.yml. При изменении этого файла от пользователя gitlab-ci-distributor рассылаются коммиты в другие проекты.

Для файлов сборки можно выбрать: либо добавлять их в коммит, либо в .gitlab-ci.yml у проектов, в задачу сборки, добавить git clone проекта common-ci-config.

Плюсы такого подхода:

  • В каждом проекте становится видно, когда изменился .gitlab-ci.yml и кто его изменил. Пропадает проблема хранения эффективного .gitlab-ci.yml, т.к. в каждом проекте всегда видно полную версию без include.
  • В проектах, где не нужна последняя версия сборочных файлов на момент сборки, сборочные файлы добавляются коммитом.
  • В проектах, где на момент сборки всегда нужна последняя версия, сборочные файлы клонируются.
  • Можно часть файлов добавлять в коммит, а часть — использовать из клонированной копии.
  • Можно реализовать концепцию include для .gitlab-ci.yml с помощью вызова скрипта. То есть, если нужно, чтобы .gitlab-ci.yml при сборке всегда использовал последнюю версию конфигурации тестирования, то тестирование выносится в скрипт в проекте common-ci-config.

GitLab API


Итак, проблема обозначена и есть вариант решения. Для продолжения нужно рассказать о GitLab API (документация на сайте GitLab). Понадобятся следующие методы:


Методы API можно вызывать с помощью curl, а JSON, который приходит в ответ, обрабатывать с помощью jq (документация по фильтрам).

Для вызова методов понадобится создать access token. Об этом будет дальше в статье, а пока — пример того, как получать список проектов в группе:

$ curl -s --header "PRIVATE-TOKEN: $TOKEN" https://gitlab.example.com/api/v4/groups/group-of-alike-projects/projects?simple=true |   jq -r '.[] | "\(.path_with_namespace)\t\(.id)"'


group-of-alike-projects/project-pasiphae    7
group-of-alike-projects/project-megaclite    6
group-of-alike-projects/project-helike    5
group-of-alike-projects/project-erinome    4
group-of-alike-projects/project-callisto    3
group-of-alike-projects/project-aitne    2
group-of-alike-projects/project-adrastea    1

Настройка GitLab


Вызов методов API невозможен без авторизации. GitLab предлагает авторизацию через access tokens. Чтобы получить такой токен, нужно создать отдельного пользователя, которому будут даны права на управление нужными репозиториями. Пусть это будет пользователь gitlab-ci-distributor:





Далее нужно стать этим пользователем и создать access token:



Для доступа к проектам, где нужно управлять сборочными файлами, нужно добавить пользователя gitlab-ci-distributor в группу:



Общие для проектов файлы будут храниться в проекте сommon-ci-config. Проект нужно создать в отдельной группе — например, infra. В настройках проекта добавляется секретная переменная со значением полученного токена:



Описанные действия выполняются администратором один раз. Далее вся настройка производится через файлы в репозитории common-ci-config.

Репозиторий common-ci-config


Теперь можно протестировать работу с API через GitLab CI. Для этого в проект common-ci-config добавляется простой .gitlab-ci.yml:

stages:
  - distribute

distribute:
  stage: distribute
  script:
    - ./distribute.sh

… и скрипт distribute.sh, который пока покажет информацию о коммите и проекты из выбранной группы:

#!/usr/bin/env bash

curl -s --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN" https://gitlab.example.com/api/v4/projects/infra%2Fcommon-ci-config/repository/commits/$CI_COMMIT_SHA | jq '.'

curl -s --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN" https://gitlab.example.com/api/v4/groups/group-of-alike-projects/projects?simple=true |     jq -r '.[] | "\(.path_with_namespace)\t\(.id)"'

Результат выполнения задания distribute:

Running with gitlab-runner 10.1.0 (c1ecf97f)
  on gitlab (d82a6d8f)
Using Shell executor...
Running on gitlab...
Fetching changes...
HEAD is now at 08dcc92 Initial .gitlab-ci.yml and distribute.sh
Checking out 08dcc92a as master...
Skipping Git submodules setup
$ ./distribute.sh
{
  "id": "08dcc92abf0d951194ad1ffcc23deeb875855320",
  "short_id": "08dcc92a",
  "title": "Initial .gitlab-ci.yml and distribute.sh",
  "created_at": "2017-10-25T16:35:15.000+03:00",
  "parent_ids": [
    "d9bdea91d081025c2af658209f23f684c96b5cee"
  ],
  "message": "Initial .gitlab-ci.yml and distribute.sh\n",
  "author_name": "root root",
  "author_email": "root.root@gitlab.example.com",
  "authored_date": "2017-10-25T16:35:15.000+03:00",
  "committer_name": "root root",
  "committer_email": "root.root@gitlab.example.com",
  "committed_date": "2017-10-25T16:35:15.000+03:00",
  "stats": {
    "additions": 0,
    "deletions": 0,
    "total": 0
  },
  "status": "running",
  "last_pipeline": {
    "id": 2,
    "sha": "08dcc92abf0d951194ad1ffcc23deeb875855320",
    "ref": "master",
    "status": "running"
  }
}
group-of-alike-projects/project-pasiphae    7
group-of-alike-projects/project-megaclite    6
group-of-alike-projects/project-helike    5
group-of-alike-projects/project-erinome    4
group-of-alike-projects/project-callisto    3
group-of-alike-projects/project-aitne    2
group-of-alike-projects/project-adrastea    1
Job succeeded

Доработка скрипта distribute.sh


Скрипт будет распространять общий файл .gitalb-ci.yml. Чтобы не путать его с .gitlab-ci.yml проекта common-ci-config, файл расположен в директории common. В файле описывается простое автоматическое задание:

# common/.gitlab-ci.yml
stages:
  - build

build:
  stage: build
  script:
    - echo Building project $CI_PROJECT_PATH

В скрипте distribute.sh уже есть получение информации о коммите и получение списка проектов. Чтобы в проекты попадал красивый коммит, нужно выделить имя и почту автора и полное сообщение коммита. Также нужно добавить цикл по полученным проектам и для каждого проекта вызвать метод, создающий коммит.

Доработанный distribute.sh:

#!/usr/bin/env bash

COMMIT_INFO=$(curl -s --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN" https://gitlab.example.com/api/v4/projects/infra%2Fcommon-ci-config/repository/commits/$CI_COMMIT_SHA)
# Сообщение коммита может быть многострочным, поэтому jq без -r
MESSAGE=$(echo "$COMMIT_INFO" | jq '.message')
AUTHOR_NAME=$(echo "$COMMIT_INFO" | jq -r '.author_name')
AUTHOR_EMAIL=$(echo "$COMMIT_INFO" | jq -r '.author_email')

CONTENT=$(base64 -w0 common/.gitlab-ci.yml)

PAYLOAD=$(cat <<- JSON
{
  "branch": "master",
  "commit_message": $MESSAGE,
  "author_name": "$AUTHOR_NAME",
  "author_email": "$AUTHOR_EMAIL",
  "actions": [
  { "action": "update",
    "file_path": ".gitlab-ci.yml",
    "content": "$CONTENT",
    "encoding": "base64"
  }
  ]
}
JSON
)

echo "$PAYLOAD"

curl -s --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN" https://gitlab.example.com/api/v4/groups/group-of-alike-projects/projects?simple=true |     jq -r '.[] | "\(.path_with_namespace)\t\(.id)"' |   while read project
  do
    name=`echo $project | awk '{print $1}'`
    id=`echo $project | awk '{print $2}'`
    echo Update project $name
    curl -s --request POST --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN"          --header "Content-Type: application/json"          --data "$PAYLOAD" https://gitlab.example.com/api/v4/projects/$id/repository/commits
  done

echo Stop

Результат выполнения задания distribute:



В проекте group-of-alike-projects/project-pasiphae коммит будет выглядеть так:



Результат выполнения задания build в проекте group-of-alike-projects/project-pasiphae:



Видно, что пользователь, который запускает задание, — gitlab-ci-distributor. Но при этом автор коммита — пользователь, который сделал коммит в common-ci-config.

Отключение одновременной автоматической сборки


Скрипт distribute.sh добавляет коммиты сразу в несколько проектов. Это приводит к созданию новых pipeline и одновременному запуску заданий на сборку. Такой эффект не всегда нужен. Чтобы коммит, обновляющий .gitlab-ci.yml, не запускал сборку, можно вначале задания поставить условие с предупреждающим сообщением:

script:
  - 'if [ "x$GITLAB_USER_NAME" == "xgitlab-ci-distributor" ] ; then echo -e "\033[0;31m\n\nАвтоматическая сборка после обновления .gitlab-ci.yml отключена.\n\n\033[0m"; exit 1; fi'

Внимание! Переменная GITLAB_USER_NAME появилась в GitLab 10.0 (релиз от 22 сентября 2017). В более ранних версиях есть только GITLAB_USER_ID и для условия придётся использовать ID пользователя. Этот ID можно узнать, например, выполнив задание со script: [export] или с таким запросом к API:

curl -s --header "PRIVATE-TOKEN: $DISTRIBUTOR_TOKEN" https://gitlab.example.com/api/v4/users?username=gitlab-ci-distributor | jq '.[] | .id'

Результат:



Если запустить это задание ещё раз, но от обычного пользователя, то всё выполнится успешно:



Заключение


В целом данной информации достаточно для того, чтобы дальше самостоятельно экспериментировать с массовым управлением проектами.

Для простоты экспериментов и повторения того, что описано в статье, можно установить GitLab в виртуальной машине, например, с помощью проекта gitlab-vagrant. Учтите, что придётся исправить Vagrantfile: сменить базовый образ на ubuntu/xenial64 и увеличить память vb.memory = "3072". А после запуска добавить gitlab-runner по инструкции.

При разработке решения использовались следующие ресурсы:


P.S.


Читайте также в нашем блоге (и подписывайтесь, чтобы не пропустить новые публикации!):

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


  1. SlavikF
    26.10.2017 21:29

    Возникает чувство, что статья написана несколько абстрактно. Может быть стоило вначале более чётко обозначить проблему, которую вы пытались решить? А то возникает подозрение, что здесь приведён список костылей для конкретной ситуации и не совсем понятно, что я могу взять из этого для себя.


    1. diafour Автор
      27.10.2017 12:55

      Есть сотня проектов, у которых один и тот же .gitlab-ci.yml, Dappfile и папка с helm chart-ом. Задача — обновлять эти файлы, желательно из одного репозитория.
      На практике это может быть что угодно, лишь бы этого много и не хотелось бегать по репозиториям, обновлять файлы. Например, статические сайты на jekyll, собираемые в docker-образ (сайты правят отдельные команды, за сборку отвечает одна команда). Внутренние java-библиотеки, собираемые в jar-артефакты, да вообще любые компоненты, которые не требуют индивидуальных правил сборки (тоже самое — компоненты правят разные люди, сборкой занимается одна команда).


  1. scor2k
    26.10.2017 21:59

    Что бы не запускать сборку надо в сообщении к коммиту написать в любом месте “[CI SKIP]”. Это, по-моему, немного проще :)


    1. diafour Автор
      27.10.2017 13:24

      Это уже вопрос требований в конкретной задаче. Дело в том, что по коммиту с [CI SKIP] pipeline создаётся без задач. Поэтому, чтобы запустить сборку с новой конфигурацией, потребуется, например, добавить новый коммит или подмёржить master в ветку release — повторюсь, всё зависит от требований и от принятого на проекте процесса внесения изменений.


      P.S. Навесить тэг на прилетевший коммит с [ci skip] — не вариант, pipeline тоже будет пустой.


      Pipelines

      image


  1. allburov
    29.10.2017 18:35

    Интересное решение, как раз сейчас изучаем возможности gitlab-ci, чтобы переехать на него.
    Верно понимаю, что разработка идёт в мастер-ветках и новый yml коммитится именно туда?


    1. diafour Автор
      30.10.2017 13:53
      +1

      В проекте, для которого создавалось решение — да, достаточно коммита в master, а из него cherry-pick в feature-ветку.
      Если проекты не совсем однотипны, есть отклонения, то можно в переменных проекта указывать особенности, а в distribute.sh получать переменные через API: https://docs.gitlab.com/ce/api/project_level_variables.html
      Например, где-то нужно в master сделать коммит, а где-то в develop.