Привет, Хабр! На связи Юрий Власенко, Senior DevOps инженер в РТЛабс. Сегодня я расскажу о том, как наша команда автоматизировала сложный, бюрократизированный и многоуровневый релизный процесс компании и превратила его в тестируемый объектно-ориентированный код на Groovy (Jenkins).

Как мы к этому пришли

Изначально у нас не было цели построить какую-то универсальную релизную платформу на все случаи жизни. Мы решали вполне конкретную задачу: сделать пайплайн, который по одной кнопке соберёт и выведет релиз для одного конкретного блока разработки. Ну то есть ту самую заветную кнопку, нажав которую в 3 часа ночи, ты с гарантией в 99,9% будешь уверен в том, что релиз успешно добрался до прода.

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

В результате процесс был:

  • неоднородным;

  • плохо предсказуемым;

  • сложным в сопровождении;

  • зависимым от того, кто именно сейчас его ведёт.

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

  • определить, какие задачи входят в релиз;

  • найти связанные с ними изменения в репозиториях команды;

  • создать и слить все нужные ветки;

  • собрать нужные приложения;

  • зафиксировать состав поставки;

  • провести релиз по средам с определением порядка установки приложений;

  • вернуть пользователю прозрачный статус по всей цепочке.

Мы провели брейншторм и решили решили создать OneClick – фреймворк доставки кода, позволяющий выкатить релиз одной кнопкой (ну почти). Тогда мы ещё не знали, что в один прекрасный день наш код станет стандартом доставки для десятков слабосвязанных или вообще не связанных друг с другом информационных систем компании.

Из чего состоит OneClick

Не Jenkinsfile единым

Уже на этапе раннего планирования стало понятно, что реализовать наш замысел можно только на основе полноценной системы со своей логикой, правилами и точками отказа. Значит, относиться к ней как к очередному длинному pipeline-скрипту нельзя. При развитии проекта сложность будет только накапливаться, ив какой-то момент это выльется или в невозможность доработки без глобального рефакторинга, или в невозможность доработки как таковой.

И проблема была не в Jenkins или Pipeline as Code. Сложная релизная логика очень плохо живёт в форме длинного процедурного сценария. Особенно если этот сценарий одновременно:

  • работает с большим количеством API (ArgoCD, Jira, Gitlab, Vault, Harbor, SonarQube, etc.) и обеспечивает взаимодействие объектов, полученных из этих API;

  • работает с несколькими источниками конфигурации;

  • динамически определяет состояние релиза;

  • динамически формирует способ сборки приложений;

  • обеспечивает единый процесс доставки по средам;

  • выполняет логирование в нужном формате и с нужным уровнем на всех стадиях пайплайна.

Поэтому, основная логика всего процесса живёт в библиотеке как объектная модель, а Jenkins просто становится тонкой точкой входа и средой исполнения.

Для написания Jenkinsfiles мы выбрали scripted парадигму: для нас это было не стилистическим предпочтением, а практическим выбором, дающим больше свободы в действиях и раскрывающим весь потенциал Groovy.

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

@Library('oneclick-lib@main') _

import com.company.cicd.ReleaseContext
import com.company.cicd.WorkflowFirst
import com.company.cicd.WorkflowSecond

WorkflowFirst workflowFirst
WorkflowSecond workflowSecond

node {
  timestamps {
    stage('First') {
      ReleaseContext context = ReleaseContext.from(env, params)
      workflowFirst = new WorkflowFirst(this, context)
      midResult = workflowFirst.run()
    stage('Second') {
      Context context = ReleaseContext.from(env, params)
      workflowSecond = new WorkflowSecond(midResult)
      result = workflowSecond.run()
    }
  }
}

Сам pipeline почти ничего не «знает» о релизной логике. Он лишь передает управление библиотеке и определяет последовательность запускаемых действий.

Если смотреть на процесс в целом, то OneClick у нас распадается на три шага, довольно прозаичных и знакомых каждому, кто так или иначе имеет отношение к процессам доставки кода:

  1. подготовка: определить релиз как сущность. В нашем случае за это отвечает так называемый JiraOps, когда состав и состояние релиза автоматизация получает из Jira;

  2. вuild: понять, какие приложения нужно собирать и делать это единообразно, с зависимостями, простановкой версий и получением артефактов;

  3. deploy: довести релиз по средам от dev до prod;

Именно эта цепочка являлась основополагающим принципом и нашей рабочей моделью релизного процесса. Со временем она обросла большим количеством проверок и дополнений (сканирование SAST, сканирование SonarQube, проверка покрытия тестами, запуск кастомных автотестов и пр.). Но основу по-прежнему составляют эти три крупных блока, которые в то же время являются тремя разными пайплайнами, способными запускаться независимо друг от друга.

Ниже представлена примерная блоксхема взаимосвязи различных пайплайнов всего процесса:

Каждый из этих блоков достоин отдельной статьи, но в контексте общего рассмотрения процесса остановлюсь на этом кратко.

Часть первая: JiraOps

Первая задача и первый пайплайн – OneClickRelease:

  • отвечает за формирование релиза;

  • является единой точкой входа для установки релиза;

  • триггерит все зависимые джобы.

Что под капотом:

  • Jenkins сам, по информации из Jira, определяет задачи, входящие в релиз;

  • по этим задачам ищет связанные MR в проектах команды;

  • создает релизную ветку во всех проектах команды;

  • мержит в неё нужные изменения;

  • на основании собранных приложений и значений, полученных из служебных полей Jira, формирует файл состава релиза;

  • создает специальную релизную задачу в Jira. В ней описан состав поставки, план установки, версии, согласования, даты и прочая информация, в полной мере описывающая релиз. Эта задача затем динамически изменяется в зависимости от состояния релиза.

Ниже представлен псевдокод workflow этого процесса:

class ReleaseScopeWorkflow {

  ReleaseScope build(String releaseKey) {
    def issues = jira.findIssuesByRelease(releaseKey)
    def mergeRequests = issues.collectMany { issue ->
        git.findMergeRequestsByIssue(issue.key)
    }
    
    def releaseBranch = git.createBranch("release/${releaseKey}")
    
    mergeRequests.each { mr ->
      git.mergeMrToBranch(mr, releaseBranch)
    }
      
    def releaseFile = releaseFileBuilder.build(
      branch: releaseBranch,
      projects: [],
      specs: []
    )
      
    jira.createReleaseIssue(
      key: releaseKey,
      branch: releaseBranch,
      scopeFile: releaseFile.path
    )
  
    new ReleaseScope(
      branch: releaseBranch,
      projects:[“proj1”, “proj2”],
      specs: [“spec1”, “spec2”],
      releaseFile: releaseFile
    )
  }
}

На этом этапе релиз перестает быть «примерным списком изменений» и становится формально собранной сущностью:

  • релиз-менеджер не собирает и не заполняет состав релиза руками, это происходит автоматически;

  • разработчик будет уверен, что каждый MR будет автоматически найден и включен в релиз;

  • тестировщик, опираясь на вывод логов, может сразу объективно оценить, какие задачи готовы и поставлены на стенд.

  • соблюдается консистентность поставки, т. е. все компоненты релиза, готовые к выводу, гарантировано будут установлены в процессе выполнения пайплайна.

Такой подход минимизирует риски того, что какой-то компонент не попадёт в релиз или что-то проскочит туда без должных проверок.

Часть вторая: Build

Следующим важным этапом является сборка приложений.

Мы выбрали конфигурируемую сборку, при которой процесс сборки каждого приложения описывается в yaml-файле несколькими полями проекта. В них определяются окружения (Java, Python, Go, Node) и инструменты сборки (Gradle, Maven, Docker), их версии, зависимости проекта, команды сборки и описание возвращаемых артефактов. Сам pipeline не знает заранее, как и с помощью чего собирается проект. Он получает описание проекта и выполняет нужный сценарий динамически, в процессе своего выполнения.

Условно, файл конфигурации, на основании которого выполняется сборка и деплой, можно представить так:

projects:
 project_a:
  framework: java
  frameworkVersion: 21.0.1
  builder: maven
  builderVersion: 3.9.9
  buildCommand: mvn clean install
  apps:
    app1:
     exclude_envs: [stand1, stand2]
     wave: 1
    app2:
     wave: 2
 project_b:
  framework: python
  frameworkVersion: 3.10
  builder: docker
  dockerFile: src/docker/Dockerfile
  apps:
    app3:
     deploy: false
    app4:
     wave: 3

За счёт этого один и тот же пайп может единообразно собирать и деплоить практически любые приложения, не превращаясь в набор специальных веток под каждый проект.

Часть третья: Deploy

Для деплоя приложений мы используем GitOps в виде ArgoCD для большей части приложений и Ansible для того, с чем по тем или иным причинам ArgoCD не справился. Несмотря на то, что деплой непосредственно поручен сторонним инструментам, оркестрация всего процесса остается за Jenkins.

Jenkins в этой модели не просто «запускает что-то дальше», а управляет процессом через API ArgoCD:

  • инициирует деплой;

  • рефрешит и синкает то, что нужно и с нужными параметрами;

  • асинхронно получает статусы приложений и возвращает пользователю внятную обратную связь в виде состояния приложения и его последних логов.

И управляет процессом деплоя через Anible:

  • скачивает нужные роли, inventory и зависимости;

  • запускает роли с нужными параметрами на основе файла описания ansible деплоя;

  • формирует параметры конфигурации роли — параметры, которые переопределяют default-значениями из репозитория конфигурации;

  • управляет очерёдность запуска ролей.

Псевдокод для наглядности и визуализации процесса:

class DeploymentOrchestrator {

  DeploymentResult deploy(ReleaseScope scope, String environment) {
    def appName = argoClient.startDeployment(
      environment: environment,
      releaseFile: scope.releaseFile.path
    )
  
    while (true) {
      def status = argoClient.getStatus(appName)
      
      logger.info("Deploy status for ${appName}: ${status.phase}")
      
      if (status.phase in ["FAILED", "ERROR"]) {
        return DeploymentResult.failed(status.message, lastAppLogs)
      }
      
      if (status.phase == "HEALTHY") {
        return DeploymentResult.success(appName)
      }
      
      sleep(10)
    }
  }
}

Идемпотентность процесса

Отдельной задачей было сделать весь пайплайн идемпотентным. На этапе формирования страниц, веток и файлов описания поставки релиза достаточно проверить текущее состояние этих объектов, чтобы понять – требуется ли их изменение или нет. На этапе деплоя состояние приложений контролирует ArgoCD или идемпотентность Ansible роли. Для этапа сборки нужно было выработать максимально простой способ определения состояния этой самой сборки.

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

Поэтому, после успешной сборки, на последний коммит релизной ветки мы просто ставим релизный тег. На следующем прогоне OneClick пайплайн смотрит, есть ли на текущем HEAD этой ветки нужный тег. Если новых коммитов не появилось, проект исключается из сборки.

class ProjectBuildWorkflow {
  void build(Project project, String releaseTag) {
    if (!buildPlanner.shouldBuild(project, releaseTag)) {
      logger.info("Skip build for ${project.name}: no new commits")
      return
    }
    
    shell.run(project.buildCommand)
    artifacts.publish(project.artifactPath)
    git.tag(
      repo: project.repo,
      commit: git.lastCommit(project.releaseBranch),
      tag: releaseTag
    )

    git.pushTag(project.repo, releaseTag)
  }
}

На практике, помимо идемпотентности, этот подход значительно сказался на времени выполнения пайплайна: OneClick не тратит время на повторную сборку уже собранного состояния ветки и ведёт себя предсказуемо при повторных прогонах. Фактически разница между первым и последующими запусками пайплайна может достигать десятков минут, т. к. выполнение действий по изменению состояния релиза не требуется.

Архитектура библиотеки

Чтобы OneClick не превратился в разросшийся Jenkins-скрипт, на раннем этапе было принято решение вынести основную логику в shared library и условиться, что она не будет являться просто набором переиспользуемых методов, станет полноценным фреймворком.

В попытках добиться максимального уровня разделения ответственности, тестируемости и масштабируемости архитектура слоёв библиотеки претерпевала не один рефакторинг. Ниже представлена примерная структура в её текущей реализации:

cicd
 ├─── clients
 │ ├─── jira
 │ │ └─── model
 │ └─── gitlab
 │ │ └─── api
 │ └─── …
 ├─── config
 ├─── core
 │ └─── git
 │ │ └─── model
 │ └─── …
 ├─── exceptions
 ├─── jenkins
 │ ├─── steps
 │ │ ├─── conflict
 │ │ │  ├─── context
 │ │ │  ├─── provider
 │ │ │  └─── resolvers
 │ │ └─── …
 │ └─── workflows
 │   ├─── merge
 │   └─── …
 ├─── models
 │ ├─── dto
 │ └─── …
 └─── utils

В core живёт чистая бизнес-логика, которая ничего не знает о Jenkins. Это модели, парсеры и код, принимающий решения на основе результатов различных обработок и других входных данных.

Например, разбор конфликтов во время git merge:

class ConflictOutputParser {
  private static final PATTERN = ~/CONFLICT.*in\s(\S+)/
   
  static List<ConflictFile> parse(String mergeOutput) {
    if (!mergeOutput) {
      return []
    }
    
    def conflicts = []
    def matcher = mergeOutput =~ PATTERN
    matcher.each { match ->
      conflicts << new ConflictFile(path: match[1])
    }
    
    return conflicts
  }
}

Этот маленький фрагмент хорошо показывает главную идею резделения: мы вынесли логику из Jenkins-скрипта в обычный код, который можно изолированно тестировать и развивать.

Отдельно в пакет вынесены clients для работы с внешними API: Gitlab, Jira, Vault и многими другими системами. За счёт этого Jenkins перестал быть местом, где просто живёт вся релизная логика, но стал orchestration-слоем поверх библиотеки.

В пакете jenkins находится слой, завязанный на Jenkins-контекст:

  • steps — атомарные операции, выполняющие одно конкретное действие;

  • workflows — сценарии, которые собирают несколько шагов в целостное поведение, либо сценарии сценариев. Именно здесь у нас живёт не просто техническое действие, а Domain-Driven Design.

Например, merge с попыткой автоматического разрешения конфликтов:

class MergeWorkflow {
  private final GitStep gitStep
  private final ConflictResolverStep conflictResolverStep
    
  boolean merge(String sourceBranch, String targetBranch, allowUnresolved = false) {
    try {
      MergeResult mergeResult = gitStep.merge(sourceBranch)
        
      if (mergeResult.hasConflicts()) {
        List<ConflictFile> conflictFiles = mergeResult.conflicts*.path
          
        logger.logWarn("Обнаружены конфликты в файлах ${conflictFiles}")
        
        ResolutionRequest resolutionRequest = new ResolutionRequest(
          conflictFilePaths: conflictFiles,
          targetBranch: targetBranch,
          sourceBranch: sourceBranch,
          failOnUnresolved: allowUnresolved
        )
        ResolutionResult resolutionResult = conflictResolverStep.resolve(resolutionRequest, gitStep)
          
      if (resolutionResult.notResolvedButAllow) {
        logger.logWarn("Конфликты не решены, но это допустимо")
        return false
      }
        
      if (resolutionResult.fullyResolved) {
        gitStep.commit("Merge branch ${sourceBranch} into ${targetBranch} (auto-resolved conflicts)")
      } else {
        throw new PipelineException("Не удалось решить конфликты:" +
         " ${resolutionResult.unresolvedFiles}")
      }
        
      gitStep.push(targetBranch, buildConfig.gitToken)
      return true
        
    } catch (Exception ex) {
      script.error("Возникла ошибка при разрешении конфликтов: ${ex.message}")
    }
  }
}

На этом примере хорошо видно, что workflow описывает именно логику процесса:

  • merge без конфликтов проходит;

  • если конфликты не разрешены и их существование не допустимо, то процесс останавливается;

  • если конфликты не разрешены, но их существование допустимо, то возвращается предупреждение, но пайплайн может идти дальше.

Как мы тестируем пайплайны

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

В итоге мы пришли к тому, что неплохо было бы тестировать пайплайны как код. Причём не только с помощью unit тестов, но и с проверкой логики выполнения всего сценария. В нашей команде есть бывшие (хотя таких, как известно, не бывает) автотестеры, которые с радостью взялись за эту интересную и довольно редко встречающуюся в CI/CD задачу. В качестве инструмента тестирования был выбран стандартный фреймворк тестирования Java-приложений — Junit.

Условно, все тесты OneClick можно разбить на 3 основных уровня тестирования. Все они так или иначе наследуются от jenkins.unit.BasePipelineTest с большим количеством моков и переопределений реально существующих в Jenkins методов.

Уровень 1: unit-тесты для чистой логики

На первом уровне идут unit-тесты. Например, тот же парсер конфликтов можно тестировать вообще без Jenkins:

@Test
void shouldParseConflictFilesFromGitOutput() {
  def output = """
    Auto-merging Chart.yaml
    CONFLICT (content): Merge conflict in Chart.yaml
    Auto-merging package.json
    CONFLICT (content): Merge conflict in package.json
  """

  def conflicts = ConflictOutputParser.parse(output)
  
  assertThat(conflicts*.path)
    .containsExactly("Chart.yaml", "package.json")
  
  assertThat(ConflictOutputParser.hasConflicts(output)).isTrue()
}

Уровень 2: unit-тесты для workflow-логики

На втором уровне тестируется уже сценарная логика через моки steps и зависимостей.

Например, MergeWorkflow можно проверять так:

@Test
void shouldResolveConflictsAndCommitForEnvRepo() {
  def mergeResult = MergeResult.withConflicts(
    ["Chart.yaml", "package.json"],
    "conflicts"
  )

  def resolutionResult = new ResolutionResult(
    resolvedContent: [:],
    unresolvedFiles: [],
    fullyResolved: true
  )

  when(gitStep.merge("feature/test")).thenReturn(mergeResult)
  when(conflictResolver.resolve(any(), eq(gitStep))).thenReturn(resolutionResult)
  
  def result = workflow.merge("feature/test", "main")
  
  assertTrue(result)
    
  verify(gitStep).add(".")
  verify(gitStep).commit("Merge feature/test into main (auto-resolved)")
  verify(gitStep).push("main", "test-token")
}

Или негативный сценарий:

@Test
void shouldThrowExceptionWhenConflictsNotResolved() {
  def mergeResult = MergeResult.withConflicts(["Chart.yaml"], "conflicts")
  
  when(gitStep.merge("feature/test")).thenReturn(mergeResult)
  when(gitStep.getRepoPath()).thenReturn("team/service")
  
  def ex = assertThrows(RuntimeException.class) {
    workflow.merge("feature/test", "main")
  }
  
  assertTrue(ex.message.contains("Conflicts in regular repo"))
    
  verify(conflictResolver, never()).resolve(any(), any())
  verify(gitStep, never()).commit(any())
  verify(gitStep, never()).push(any(), any())
}

Тут уже тестируются реальные правила процесса, а не только слой данных или DTO.

Уровень 3: integration-тесты

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

Здесь поднимается подготовленное Jenkins-подобное окружение:

  • подгружается shared library;

  • регистрируются Jenkins DSL-методы;

  • подставляются замоканные параметры окружения и учётные записи;

  • статические зависимости подменяются моками;

  • затем запускается сам скрипт пайплайна.

Условный setup выглядит так:

@BeforeAll
void setUp() {
  super.setUp()
    
  helper.libLoader.preloadLibraryClasses = false
  
  def (name, branch) = parseLibNameAndBranch(scriptText)
  def lib = library(name)
    .defaultVersion(branch)
    .targetPath("<notNeeded>")
    .retriever(projectSource())
  helper.registerSharedLibrary(lib.build())
    
  def scriptText = getScriptText(scriptName)
  saveAndLoadScript(scriptText)
    
  helper.registerAllowedMethod("findFiles", [Map])
  helper.registerAllowedMethod("modernSCM", [Map])
  helper.registerAllowedMethod("hidden", [Map])
  helper.registerAllowedMethod("extendedChoice", [Map])
  …
  addCredential("credId_1", TEST_CRED_1)
  addCredential("credId_2", TEST_CRED_2)
    
  addEnvVar("WORKSPACE", "/workspace")
  
  MainClassLib.utils = new MockUtils()
}

А сам интеграционный тест уже очень похож на проверку реальной джобы в Jenkins:

@Test
void mainReleasePipeSuccess() {
  addEnvVar("JOB_NAME", "job_param_value")
  
  addParam("release", "release_num_value")
  addParam("deployTo", "stand_deploy_value")
  
  runScript(script)
  
  assertJobStatusSuccess()
  assertThat(buildLog).contains("some_log_from_success_job")
}

Или можно проверять последовательность значимых этапов:

@Test
void mainReleasePipeDependencyResolvingTest() {
  addEnvVar("JOB_NAME", "job_param_value")
  
  addParam("release", "release_num_value")
  addParam("deployTo", "stand_deploy_value")
  
  runScript(script)
    
  assertThat(buildLog).containsSequence(
    "Release scope collected",
    "Release branch created",
    "Projects build planned",
    "Deployment started",
    "Deployment finished"
  )
}

То есть мы проверяем не только отдельные классы, но и сам сценарий исполнения пайплайна.

Запуск тестов выполняется с помощью Gitlab CI в репозитории библиотеки по событию создания MR.

В результате внедрения тестирования количество отказов сборок сократилось практически до нуля. Любой новый функционал сразу покрывается тестами, код проходит кросс-ревью, а любое изменение перестало быть ожиданием нового потока задач на низком старте.

Вместо вывода

Если попытаться сформулировать основной тезис статьи, то звучать он будет так: как только релизный процесс начинает жить на пересечении нескольких инструментов и включает в себя не только стандартные задачи CI/CD, то он перестает быть просто автоматизацией.

В этот момент лучше относиться к нему как к программному продукту:

  • с архитектурой;

  • явным разделением слоев;

  • доменной логикой, вынесенной из средства интеграции ПО;

  • тестами на нескольких уровнях;

  • нормальной инженерной ценой изменений.

Да, это требует больше усилий, чем написать очередной Jenkins-скрипт. Но если процесс действительно критичен, эти усилия окупаются довольно быстро, в чём мы убедились на практике.

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