Вступление
Хабр, привет! На связи Владимир, DevOps-инженер компании Совкомбанк Технологии. В этой статье расскажу о компонентах GitLab, способах их применения и том, как они помогли нам с настройкой CI/CD на проектах.
Как мы настраивали CI/CD раньше
Во времена, когда DevOps практики только начали зарождаться в нашей компании, сформировалась определенная структура файлов и каталогов, где были описаны необходимые пайплайны.
Как обычно в корне проекта находился файл .gitlab-ci.yml, в котором мы описывали стадии, шаблоны с переменными для каждого стенда, шаблоны с самими скриптами для различных тестов, сборки образов, сканирования собранных образов на уязвимости, деплоем в K8S или на docker-хосты и удалением текущего деплоя. Отдельно в каталоге ci-cd-files мы храним Dockerfile и yml-файлы для пайплайнов, содержащих в себе описание заданий, в которые подключаются шаблоны из .gitlab-ci.yml. Также в корне проекта есть отдельный каталог helm, в котором по началу находился чарт с values.yaml для него, а, в последствии, когда мы написали собственный «универсальный» чарт, только values.yaml для каждого стенда. Но сейчас не про чарт и его конфиг.
Из проекта в проект конфигурации пайплайнов могли получать изменения. В основном изменения касались именно скриптов, а набор заданий был стандартным для большей части проектов. И каждый раз, при настройке нового проекта или изменении старого в части скриптов, по «вине» изменений в работе инфраструктурных сервисов, нам приходилось вносить изменения в каждый скрипт для каждого проекта отдельно. Например, в какой-то момент произошли изменения в нашем хранилище Docker-образов - скрипт по сканированию образов на уязвимости и выгрузке этих уязвимостей сломался и ничего не выводил. Путем долгих поисков вместе с «соседней» командой девопсов мы обнаружили причину в изменении запросов к АПИ. Теперь дополнительно требовалось передавать определенный заголовок. После изменения скрипта мы снова начали получать результаты сканирования, но появилась задача распространить изменения на все проекты. После того, как мы с этим успешно справились, зародилась мысль написать общие шаблоны, которые могли бы использоваться если не во всех, то хотя бы в большей части банковских проектов.
Сначала это были локальные шаблоны для самых крупных проектов или для отдельных команд, у которых процесс настройки CI/CD отличался парочкой переменных.
Результаты радовали, а одна из команд самостоятельно развила эту идею и практически реализовала GitOps прямо в GitLab. Впоследствии мы виделись с этой командой только по каким-нибудь проблемам в самом K8S или других инфраструктурных сервисах.
Но все равно оставалась другая бо́льшая часть проектов, в которых использовался старый формат CI/CD. Пусть и немного обновленный, приведенный к более понятному и красивому формату описания пайплайнов.
Прежде, чем я расскажу о способах решения этой проблемы, раскрою описание компонентов, чтобы вы лучше понимали, о чем пойдет речь дальше.
Описание компонентов
Компоненты в виде экспериментальной функции были добавлены в GitLab 16.0
В Совкомбанк Технологиях используется GitLab 17-й версии. Если вы решили «потрогать» компоненты собственными руками – рекомендую использовать версию не ниже 17. Начиная с этой версии компоненты стали общедоступными, а их функционал неплохо расширился, по-сравнению с версией 16.
Заглянем на страницу документации компонентов.
Компоненты CI/CD – это блок конфигурации пайплайна, который можно повторно использовать.
И тут может возникнуть вопрос:
– Чем это отличается от шаблонов, которые мы подключаем через include? Например, template, remote или project.
Во-первых, у нас появился новый блок в include – component. Дальше больше, вернемся к описанию компонентов:
Компоненты можно настроить с помощью входных параметров для более динамичного поведения.
А вот тут уже становится интереснее: для компонентов появляется блок inputs, благодаря которому можно передать в подключаемый компонент значения определенных параметров. Но об этом позже, а пока снова обратимся к документации:
Компоненты CI/CD похожи на другие виды конфигураций, подключаемых с помощью include, но имеют ряд преимуществ.
Посмотрим, что это за преимущества:
– Компоненты могут быть перечислены в каталоге CI/CD;
– Компоненты могут быть выпущены и использованы с определенной версией;
– В одном проекте можно определить несколько компонентов и создавать для них версии одновременно.
По второму и третьему пунктам все более-менее понятно, а вот обсудить, что такое каталог CI/CD будет не лишним.
Каталог CI/CD – это список проектов с опубликованными компонентами CI/CD, которые вы можете использовать для расширения своего рабочего процесса CI/CD
Все созданные проекты компонентов и сами компоненты можно посмотреть на странице <your-instance>/explore/catalog. Там будут перечислены все проекты, а внутри них все компоненты определенного проекта с описанием параметров и документацией к проекту.
Немного практики
Начнем с простого: создадим проект для наших компонентов, сделаем из него элемент каталога CI/CD, добавим туда базовый компонент и попробуем подключить его к нашему проекту.
Создаем наш новый проект, выдаем ему красивое имя и обязательно просим GitLab создать в проекте файл README.

После создания проекта нужно перейти в Settings -> General -> Naming, description, topics и заполнить описание проекта. Этого требует GitLab для каталогов CI/CD.

Дальше идем в Settings -> General -> Visibility, project features, permissions, находим пункт CI/CD Catalog project и активируем его

В проекте создаем каталог templates, в который и будем складывать наши компоненты. Я назову первый компонент example.yml. Его содержимое:
Скрытый текст
spec:
  inputs:
    message:
      default: "Сообщение по умолчанию."
      description: 'Сообщение для джобы.'
    extra-message:
      default: false
      type: boolean
      description: 'Включает вывод дополнительного сообщения.'
    array-script:
      default:
        - echo "Скрипт №1"
        - echo "Скрипт №2"
      type: array
      description: 'Массив скриптов для джобы.'
    port:
      default: 8080
      options:
        - 8080
        - 9000
        - 3000
      type: number
      description: 'Номер порта.'
    version:
      regex: ^v\d\.\d+(\.\d+)$
      description: 'Номер версии.'
---
TEST:
  stage: test
  image: $CI_REGISTRY/common-alpine:6.13
  script:
    - echo $[[ inputs.message ]]
    - |
      if $[[ inputs.extra-message ]]; then
        echo "Дополнительное сообщение"
      fi
    - |
      echo "Порт: $[[ inputs.port ]]"
      echo "Версия: $[[ inputs.version ]]"
  after_script: $[[ inputs.array-script ]]
  tags:
    - public
Описание компонентов следует начинать с блока spec:inputs. В этом блоке необходимо описать параметры компонента, которые мы сможем определять/переопределять. Все возможные параметры должны быть определены в spec:inputs.
По умолчанию для параметра используется тип string. На выбор нам дается четыре типа:
– string – тип по умолчанию, принимает на вход строковое значение
– number – принимает на вход числовое значение
– array – принимает на вход допустимый синтаксисом YAML массив. Более сложные функции, такие как !reference, не могут быть использованы
– boolean – принимает на вход true/false
Обязательность параметра определяется наличием поля default – при его отсутствии поле является обязательным и должно быть определено при подключении компонента.
Также параметры могут иметь следующие поля:
– options – задает список из вариантов, которые можно передать в параметре. Значение, которое не совпадает со списком, приведет к ошибке в работе пайплайна. Применяется ко всем типам, кроме boolean.
– regex – задает регулярное выражение, которому должно следовать значение. Шаг влево/вправо – ошибка в работе пайплайна. Применяется только к типу string.
Как только закончили описание всех параметров – необходимо в новой строке написать ”---“. Это требуется для разделения блоков с параметрами и основным кодом. После этого можно приступить к описанию заданий/шаблонов.
Чтобы использовать описанные параметры, их нужно записать в таком виде:
$[[ inputs.<parameter-name> ]]
Но, перед тем как подключать наш новый компонент, нужно подготовить релиз. Для этого в корне проекта создаем файл .gitlab-ci.yml со следующим содержимым:
Скрытый текст
include:
  - component: [ДАННЫЕ УДАЛЕНЫ]/components/templates/default@1.18.3
stages: [release]
create-release:
  stage: release
  script: echo "Creating release $CI_COMMIT_TAG"
  rules:
    - if: $CI_COMMIT_TAG
      when: always
  release:
    tag_name: $CI_COMMIT_TAG
    description: "Release $CI_COMMIT_TAG of components in $CI_PROJECT_PATH"Здесь я подключаю собственный компонент, в котором описал образ по умолчанию для запуска заданий и тег раннера. Но это не важно. Важно то, что ниже. А ниже у нас стандартное задание по выпуску релиза, которое запустится только при создании тега из ветки.
Сохраняем изменения и идем выпускать тег:


Для создания релизов следует использовать семантическое версионирование https://semver.org/. Например, 1.0.0, 2.3.4 или 1.2.2-alpha. При этом, если при подключении компонента указать @~latest, будет использована последняя версия, соответствующая маске X.Y.Z. Делать так, конечно же, не рекомендуется.
Создаем тег и ждем выполнения задания по выпуску релиза.
Теперь займемся подключением компонента в проект.
Создаем новый проект, если его еще нет и добавляем туда файл .gitlab-ci.yml со следующим содержимым:
Скрытый текст
include:
  - component: [ДАННЫЕ УДАЛЕНЫ]/habr-example-component/example@0.0.1
    inputs:
      version: v1.2.3
stages: [test]Для подключения компонента используем include:component. Адрес подключения указываем следующим образом: <your-gitlab-instance>/<path/to/project>/<component-name>@<version>, где:
<your-gitlab-instance> - адрес GitLab
<path/to/project> - путь до проекта с компонентами
<component-name> - название компонента без указания расширения файла
<version> - версия релиза
Так как в компоненте параметр version определен без поля default, он является обязательным, указываем его через inputs:<parameter-name>. Не забываем, что на этот параметр мы «навесили» regex, а значит нужно указать версию, которая подойдет нашему регулярному выражению.
Сохраняем изменения и идем смотреть на логи нашего задания:

Как видим, все прекрасно работает. Версия соответствует значению параметра.
Но есть и другие параметры, попробуем поменять все:
Скрытый текст
include:
  - component: [ДАННЫЕ УДАЛЕНЫ]/habr-example-component/example@0.0.1
    inputs:
      version: v1.2.3
      message: "Хабр, вам тут сообщение!"
      extra-message: true
      array-script:
       - echo "Теперь этот параметр содержит только один скрипт."
      port: 9000Отлично, поменяли значение параметра message, переключили extra-message на true, переопределили массив array-script и указали новый порт для параметра port из списка options.
Смотрим результат:

Все работает!
Давайте сломаем наш пайплайн. Для этого изменим version, чтобы значение не соответствовало regex или укажем новый порт, который отсутствует в options. Я поменяю порт:
Скрытый текст
include:
  - component: [ДАННЫЕ УДАЛЕНЫ]/habr-example-component/example@0.0.1
    inputs:
      version: v1.2.3
      port: 80Смотрим: Unable to create pipeline
- [ДАННЫЕ УДАЛЕНЫ]/habr-example-component/example@0.0.1:- portinput:- 80cannot be used because it is not in the list of the allowed options
Ну что же… Это было ожидаемо.
Наш краткий экскурс по самим компонентам подошел к концу. Все остальное вы сможете найти в документации GitLab.
Как мы внедрили компоненты в проекты Совкомбанк Технологий и что изменилось после этого
Я начал активно изучать тему в начале 2024 года, тогда компоненты казались мне чем-то магическим. Путем скрупулезного чтения документации и выпуска сотни релизов, пришел к оптимальному решению. После чего предоставил команде первую стабильную версию своих компонентов.
Итого получилось 3 проекта:
– stands – проект с компонентами, описывающими каждый стенд. Dev, test, stage, prod и дополнительный компонент custom для настраиваемого окружения, например, preprod;
– jobs – проект с компонентами описывающим необходимые задания для каждого стенда. Напомню, что по умолчанию их 4: сборка образа, сканирование образа на уязвимости, деплой и удаление релиза;
– templates – «основной» проект компонентов, описывающий шаблоны и скрипты.
Структура следующая:
STANDS
├─test - шаблон с переменными для test среды
├─dev - шаблон с переменными для develop среды
├─stage - шаблон с переменными для stage среды
├─prod - шаблон с переменными для prod среды
└─custom - шаблон с переменными для настраиваемой средыJOBS
├─test - стандартные задания для test среды
├─dev - стандартные задания для develop среды
├─stage - стандартные задания для stage среды
├─prod - стандартные задания для prod среды
└─custom - стандартные задания для настраиваемой средыTEMPLATES
├─default - блок default с образом и тегом для раннеров по умолчанию, а также скрипты для уведомлений в мессенджер и некоторые функции
├─dast – элемент сканирования DAST
├─build - скрипт для сборки образа. Также включает в себя функции по скачиванию секретов из хранилища секретов, необходимых на стадии сборки
├─docker-compose - скрипты для деплоя и удаления релизов через docker-compose. Также включает в себя функции по скачиванию секретов из хранилища секретов, необходимых на стадии деплоя
├─docker - скрипты для деплоя и удаления релизов через docker. Также включает в себя функции по скачиванию секретов из хранилища секретов, необходимых на стадии деплоя
├─k8s - скрипты для деплоя и удаления релизов через helm. Также включает в себя функции по скачиванию секретов из хранилища секретов, необходимых на стадии деплоя
├─sast – элемент сканирования SAST
├─sca – элемент сканирования SCA
└─scan - скрипт сканирования образов на наличие уязвимостейСтруктура может показаться не очень удобной, но так было нужно, поскольку на момент составления шаблонов, в проекте могло быть не больше 10 компонентов. Из-за этого пришлось выкручиваться с несколькими проектами. В будущем проведем рефакторинг и сформируем единый проект.
Покажу примеры текущей реализации из каждого проекта.
stands/test:
Скрытый текст
spec:
  inputs:
    environment:
      default: test
      description: 'Название окружения GitLab.'
    stand:
      default: test
      description: 'Название стенда.'
    context:
      default: NOT_REQUIRED
      description: 'Имя кластера.'
    vr-lvl:
      default: Pofig
      options:
        - Pofig
        - Negligible
        - Low
        - Medium
        - High
        - Critical
      description: 'Минимальный недопустимый уровень уязвимостей.'
    vr-exit-code:
      default: 0
      type: number
      options:
        - 0
        - 1
      description: 'Exit Code для джобы сканирования при нахождении уязвимостей.'
    node-env:
      default: ''
      description: 'Переменная окружения для Node.'
---
.test_template:
  environment: $[[ inputs.environment ]]
  variables:
    ENVRM: $[[ inputs.stand ]]
    VR_LVL: $[[ inputs.vr-lvl ]]
    VULN_EXIT_CODE: $[[ inputs.vr-exit-code ]]
    K8S_TOKEN: $K8S_TOKEN_$[[ inputs.context ]]
    K8S_URL: $K8S_URL_$[[ inputs.context ]]
    K8S_CLUSTER: $K8S_CLUSTER_$[[ inputs.context ]]
    NODE_ENV: $[[ inputs.node-env ]]jobs/test:
Скрытый текст
spec:
  inputs:
    build-extends:
      type: array
      default:
        - .test_template
        - .build_template
      description: 'Набор подключаемых шаблонов для джобы сборки образа.'
    build-rules:
      type: array
      default:
        - if: $CI_COMMIT_BRANCH == "test"
          when: always
        - if: "$CI_COMMIT_BRANCH =~ /^devops.*$/"
          when: always
        - when: never
      description: 'Набор правил для триггера джобы сборки образа.'
    build-needs:
      type: array
      default:
        - job: EMPTY_NEEDS
          optional: true
      description: 'Набор needs для джобы сборки образа.'
    build-interruptible:
      type: boolean
      default: false
      description: 'Помечает задание как прерываемое.'
    scan-extends:
      type: array
      default:
        - .test_template
        - .scan_template
      description: 'Набор подключаемых шаблонов для джобы сканирования образа.'
    scan-rules:
      type: array
      default:
        - if: $CI_COMMIT_BRANCH == "test"
          when: on_success
        - if: "$CI_COMMIT_BRANCH =~ /^devops.*$/"
          when: on_success
        - when: never
      description: 'Набор правил для триггера джобы сканирования.'
    scan-needs:
      type: array
      default:
        - job: Build App Test
          optional: true
      description: 'Набор needs для джобы сканирования образа.'
    scan-interruptible:
      type: boolean
      default: false
      description: 'Помечает задание как прерываемое.'
    deploy-extends:
      type: array
      default:
        - .test_template
        - .deploy
      description: 'Набор подключаемых шаблонов для джобы деплоя.'
    deploy-rules:
      type: array
      default:
        - if: $CI_COMMIT_BRANCH == "test"
          when: manual
        - if: "$CI_COMMIT_BRANCH =~ /^devops.*$/"
          when: manual
        - when: never
      description: 'Набор правил для триггера джобы деплоя.'
    deploy-needs:
      type: array
      default:
        - job: Scan App Test
          optional: true
      description: 'Набор needs для джобы деплоя.'
    deploy-interruptible:
      type: boolean
      default: false
      description: 'Помечает задание как прерываемое.'
    cleanup-extends:
      type: array
      default:
        - .test_template
        - .delete_command
      description: 'Набор подключаемых шаблонов для джобы очистки деплоя.'
    cleanup-rules:
      type: array
      default:
        - if: $CI_COMMIT_BRANCH == "test"
          when: manual
        - if: "$CI_COMMIT_BRANCH =~ /^devops.*$/"
          when: manual
        - when: never
      description: 'Набор правил для триггера джобы очистки деплоя.'
    cleanup-needs:
      type: array
      default:
        - job: EMPTY_NEEDS
          optional: true
      description: 'Набор needs для джобы очистки деплоя.'
    cleanup-interruptible:
      type: boolean
      default: false
      description: 'Помечает задание как прерываемое.'
---
Build App Test:
  extends: $[[ inputs.build-extends ]]
  rules: $[[ inputs.build-rules ]]
  needs: $[[ inputs.build-needs ]]
  interruptible: $[[ inputs.build-interruptible ]]
Scan App Test:
  extends: $[[ inputs.scan-extends ]]
  rules: $[[ inputs.scan-rules ]]
  needs: $[[ inputs.scan-needs ]]
  interruptible: $[[ inputs.scan-interruptible ]]
Deploy App Test:
  extends: $[[ inputs.deploy-extends ]]
  rules: $[[ inputs.deploy-rules ]]
  needs: $[[ inputs.deploy-needs ]]
  interruptible: $[[ inputs.deploy-interruptible ]]
Cleanup App Test:
  extends: $[[ inputs.cleanup-extends ]]
  rules: $[[ inputs.cleanup-rules ]]
  needs: $[[ inputs.cleanup-needs ]]
  interruptible: $[[ inputs.cleanup-interruptible ]]templates/scan:
Скрытый текст
spec:
  inputs:
    stage:
      default: scan
      description: 'Название стадии.'
    notify:
      default: false
      type: boolean
      description: 'Включает уведомления о статусе задания сканирования образа.'
---
.scan_template:
  stage: $[[ inputs.stage ]]
  variables:
    GREP_ID: '"id":*"[^"]*"'
    GREP_PACKAGE: '"package":*"[^"]*"'
    GREP_VERSION: '"version":*"[^"]*"'
    GREP_FIX_VERSION: '"fix_version":*"[^"]*"'
    GREP_SEVERITY: '"severity":*"\(Low\|Medium\|High\|Critical\)"'
  before_script:
    - !reference [.border]
    - !reference [.log]
  script:
    - |
      DEBUG=${DEBUG:-false}
      if $DEBUG; then
        set -x
      fi
      if [ -z ${SCAN_PROJECT_NAME+x} ]; then
        log "INFO" "Переменная SCAN_PROJECT_NAME не установлена. Вместо нее будет использована переменная PROJECT_NAME = ${PROJECT_NAME}."
      else
        log "INFO" "Переменная SCAN_PROJECT_NAME установлена. SCAN_PROJECT_NAME = ${SCAN_PROJECT_NAME}."
        SCAN_PROJECT_NAME=$(echo ${SCAN_PROJECT_NAME} | sed 's,/,%252F,g');
      fi
      URL=”Путь к API-методу для сканирования образа.”
    
      log "INFO" "Адрес сканирования - ${URL}."
    - curl -s -X POST -u "$CI_REGISTRY_USER":"$CI_REGISTRY_PASSWORD" "$URL"
    - count=16
    - |
      while [[ "$(curl -s -u "$CI_REGISTRY_USER":"$CI_REGISTRY_PASSWORD" -H "$HEADER" $URL)" != *"severity"* ]] && [[ 0 -lt $count ]]; do 
        (( count-- ));
        log "INFO" "Ожидание завершения сканирования.... Осталось $count попыток"
        if [ $count = 0 ]; then
          log "ERROR" "Не удалось получить статус сканирования. Попробуйте позже."
          exit 1; 
        fi; 
        sleep 20; 
      done
    - |
      curl -s -u "$CI_REGISTRY_USER":"$CI_REGISTRY_PASSWORD" -H "$HEADER" "$URL" > all_vuln.txt
      cat all_vuln.txt | grep --color=always -o ${GREP_ID},${GREP_PACKAGE},${GREP_VERSION},${GREP_FIX_VERSION},${GREP_SEVERITY} || log "DONE" "Уязвимостей не найдено."
      cat all_vuln.txt | grep -o ${GREP_ID},${GREP_PACKAGE},${GREP_VERSION},${GREP_FIX_VERSION},${GREP_SEVERITY} > vulnerability.txt || z=1;
    - |
      cat all_vuln.txt | grep -o '"severity":"'${VR_LVL}'"' > /dev/null || s=1;
      if [[ "$s" -eq "1" ]]; then
        log "DONE" "Не найдено уязвимостей уровня ${VR_LVL}."
      else
        log "ERROR" "Обнаружены уязвимости уровня ${VR_LVL}."
        exit 1;
      fi
    - NOTIFY_BY_COMPONENT=$[[ inputs.notify ]]
    - !reference [.notify, success]
  allow_failure: true
  after_script:
    - NOTIFY_BY_COMPONENT=$[[ inputs.notify ]]
    - if [ $CI_JOB_STATUS == 'success' ]; then exit 0; fi
    - !reference [.notify, error]Выглядит не так хорошо, как хотелось бы, но моя команда постоянно вносит изменения в компоненты и постепенно их улучшает.
На текущий момент статистика следующая:

Внедрили компоненты в ~700 проектах и планируем масштабироваться
Значения постоянно скачут из-за количества самих проектов. Планируем увеличивать число и расти еще. Такое различие в числах между тремя проектами на скриншоте вызвано тем, что проект templates так же содержит в себе компоненты DAST, SAST, SCA, которые могут подключаться ко всем банковским проектам, независимо от того используются остальные компоненты или нет.
Большую часть новых проектов Совкомбанк Технологий переводим на компоненты и актуализируем действующие проекты на их основе. Если сравнивать скорость настройки пайплайна на основе компонентов со старым, заметны улучшения. При знакомстве с новым шаблоном, командам нужно время, чтобы разобраться в механиках, зато потом скорость написания пайплайнов только растет. Улучшился и процесс обновления скриптов. Теперь достаточно просто выпустить новый релиз и сообщить командам о том, в каких компонентах необходимо поменять версию. Latest мы используем только в компонентах sast, sca и dast, так как они не влияют на процесс CI/CD и их поломка не тормозит выкатку релиза.
Очень полезной оказалась и возможность переопределять шаблоны, скрипты и задания. Это позволяет гибко перенастроить определенный элемент пайплайна в случае чего.
Плюсы и минусы данного решения
Плюсы
– Основные настройки пайплайнов теперь хранятся в одном месте, что позволяет один раз внести необходимые изменения, а разработчикам только поменять версию определенного компонента в сбственных проектах.
– Настройка пайплайна в собственных компонентах гибкая: любое задание можно встроить в любое место пайплайна и настроить так, что все будет работать корректно и без поломок.
– Весь цикл от CI до CD можно реализовать только с помощью компонентов.
Минусы
В течение всего процесса написания мне, конечно же, пришлось столкнуться со многими проблемами.
– Из самых серьезных хочу выделить отсутствие возможности использовать проверки IF/ELSE, как это реализовано в helm-templates. Понятное дело, что helm и GitLab совершенно разные инструменты, но функционала, аналогичного helm’у в плане рендера готовой конфигурации очень не хватало. Но и к этому можно адаптироваться.
– Еще одним минусом считаю ограничения по числу компонентов на проект. Сейчас лимит подняли до 30, но хотелось бы, чтобы его вообще не было.
– Наконец, не самая удобная настройка: начиная от инициализации самого проекта компонентов, где без документации первое время ничего не будет понятно, до процесса внедрения компонентов в проекты, особенно для новичков.
Вот таким нехитрым образом мы решили проблему комплексного обновления процессов CI/CD в банковских проектах. Есть идеи, как можно развить финтех? Добро пожаловать к нам в команду.
Буду рад ответить на ваши вопросы в комментариях!
 
           
 
SimSonic
В 2015-2017 гг. администрировал свой инстанс GitLab, а потом года до 2022 регулярно читал их новостные релизные дайджесты по 22 числам с новыми фичами. И, видимо, как только перестал читать, появились всякие прикольные штуки, как эти компоненты. Документацию очень бегло глянул, первое ощущение, что это почти тоже самое, что include project + variables, разница (плюсы и минусы) не сразу очевидна, да и вообще выглядит как фича с очень непологой кривой входа :) В общем, придётся как-нибудь разобраться с ними детально ...
Спасибо за статью )