Pull to refresh
85.6
Совкомбанк Технологии
Меняй мир финансовых технологий вместе с нами

Кастомизация GitLab: опыт Совкомбанк Технологий в написании компонентов для типовых банковских проектов

Level of difficultyMedium
Reading time14 min
Views1.5K

Вступление

Хабр, привет! На связи Владимир, 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: port input: 80 cannot 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 в банковских проектах. Есть идеи, как можно развить финтех? Добро пожаловать к нам в команду.

Буду рад ответить на ваши вопросы в комментариях!

Tags:
Hubs:
+9
Comments1

Articles

Information

Website
sovcombank.ru
Registered
Employees
5,001–10,000 employees
Location
Россия