Перейти к основному содержимому

Jenkins Shared Library — общий Groovy-код CI

Разработчику Архитектору

Jenkins Shared Library — общий Groovy-код CI

Когда в организации десятки репозиториев с похожими pipeline, копировать один Jenkinsfile в каждый — технический долг:

  • исправление deploy-шага = N pull request;
  • команды расходятся по версиям "эталонного" pipeline;
  • новичок не понимает, какой Jenkinsfile актуален.

Shared Library — отдельный Git-репозиторий с Groovy-кодом для Pipeline: global vars (vars/), классы (src/), ресурсы (resources/). Jenkins подгружает библиотеку по @Library('имя@версия') _ в Jenkinsfile.

Это тот же Groovy, что в основах — замыкания, map-литералы [key: value], GString "${env.BUILD_NUMBER}". Отличие — код выполняется в CPS (Continuation-Passing Style) sandbox Pipeline, с ограничениями на сериализацию.

Создание jobs на controller — Job DSL Playground. Минимальный pipeline — 22.md. Тесты Groovy-классов — Spock. CI/CD в целом — DevOps, повторное использование в Jenkins.


Что получится

  • репозиторий jenkins-shared-library с каталогами vars/, src/;
  • регистрация библиотеки в Global Pipeline Libraries;
  • вызов standardPipeline(...) из короткого Jenkinsfile app-репо;
  • понимание trusted vs untrusted и версионирования @v1.2.0.

Определения

ТерминОпределение
Shared LibraryВерсионируемый Git-репозиторий с переиспользуемым кодом Pipeline
Global variableФайл vars/name.groovy — вызывается как name(...) в pipeline
Global var callМетод def call(...) — точка входа global var
@LibraryАннотация импорта библиотеки в Jenkinsfile
CPSМодель выполнения Pipeline — шаги могут приостанавливаться (checkpoint)
Trusted libraryБиблиотека из доверенного Git — расширенные права, вне sandbox
RetrieverСпособ загрузки — Modern SCM (Git) или Legacy
loadЗагрузка .groovy из workspace текущей сборки (не global library)

Три уровня переиспользования

МеханизмЧто переиспользуемГде живётМасштаб
Job DSLJobs, folders, viewsInfra repoВесь controller
Shared LibraryШаги pipeline, шаблоныjenkins-shared-libraryВсе app-репо
load 'utils.groovy'УтилитыТот же репо, что appОдин проект

Library — sweet spot для standardPipeline, deployApp, notifySlack: одна правка в library → все проекты подключают новую версию через bump @Library.


Структура репозитория

jenkins-shared-library/
├── vars/
│ ├── standardPipeline.groovy
│ └── notifySlack.groovy
├── src/
│ └── com/example/ci/
│ ├── DeployHelper.groovy
│ └── VersionPolicy.groovy
├── resources/
│ └── com/example/ci/
│ └── email.html
├── test/
│ └── com/example/ci/
│ └── VersionPolicySpec.groovy # Spock — опционально
└── README.md
КаталогСодержимоеПравило именования
vars/Global vars — вызов по имени файлаnotifySlack.groovynotifySlack('msg')
src/Обычные Groovy/Java классы с packageimport com.example.ci.DeployHelper
resources/Статика для libraryResource('com/example/ci/email.html')Путь зеркалит package

Не кладите Jenkinsfile приложения в library — только общий код. App-специфика (имя сервиса, путь к Dockerfile) передаётся параметрами call(Map config).


Регистрация в Jenkins

Manage Jenkins → System → Global Pipeline Libraries → Add

ПолеПримерЗачем
Namecompany-pipeline-libСтрока в @Library('company-pipeline-lib')
Default versionmain или 1.2.0Если в Jenkinsfile версия не указана
Retrieval methodModern SCM → GitClone library repo
Project repositoryURL + credentialsДоступ controller к Git
Load implicitlyOff (рекомендуется)Явный @Library — прозрачнее
Allow overrideOn@Library('lib@feature-branch') для теста PR

Production. Фиксируйте semver-теги (1.2.0), не плавающую main — иначе ночной push в library сломает все сборки.


Global var — vars/standardPipeline.groovy

Global var — Groovy-скрипт с методом call. Jenkins вызывает его как функцию из Declarative или Scripted pipeline.

// vars/standardPipeline.groovy

def call(Map config = [:]) {
pipeline {
agent any

options {
timestamps()
timeout(time: config.timeoutMinutes ?: 30, unit: 'MINUTES')
}

stages {
stage('Checkout') {
steps {
checkout scm
}
}

stage('Test') {
steps {
sh(config.testCommand ?: './gradlew test --no-daemon')
}
}

stage('Deploy') {
when {
expression {
config.deploy == true && env.BRANCH_NAME == 'main'
}
}
steps {
sh(config.deployCommand ?: './deploy.sh staging')
}
}
}

post {
always {
junit(config.junitPattern ?: '**/build/test-results/test/*.xml')
}
failure {
notifySlack("Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
}
}
}
}

Разбор standardPipeline построчно

ФрагментСмысл
def call(Map config = [:])Единственная обязательная точка входа; [:] — пустой map по умолчанию (типы Map)
pipeline { ... }Полный Declarative pipeline внутри var — допустимо и распространено
config.timeoutMinutes ?: 30Elvis operator — значение по умолчанию (операторы § ?:)
checkout scmSCM из настройки Multibranch/Pipeline job, не из library
when { expression { ... } }Deploy только если config.deploy и ветка main
notifySlack(...)Другой global var из vars/notifySlack.groovy
GString "${env.JOB_NAME}"Переменные окружения Jenkins (22.md § env)

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


Вызов из Jenkinsfile приложения

@Library('company-pipeline-lib@1.2.0') _

standardPipeline(
testCommand: './gradlew test --no-daemon',
deploy: true,
deployCommand: './deploy.sh production',
junitPattern: '**/build/test-results/test/*.xml',
timeoutMinutes: 45
)

Разбор Jenkinsfile

СтрокаСмысл
@Library('company-pipeline-lib@1.2.0') _Импорт library версии 1.2.0; _ обязателен (placeholder, как import X as _)
standardPipeline(...)Вызов global var; именованные аргументы → ключи config map
Отсутствие pipeline { }Весь declarative pipeline внутри library

Минимальный app-репо — одна строка @Library + один вызов standardPipeline. Логика checkout/test/deploy централизована.

Сборка Gradle внутри — 23.md. Тесты Spock перед deploy — 21.md.


vars/notifySlack.groovy — маленький step

def call(String message) {
def channel = '#ci-alerts'
echo "Slack → ${channel}: ${message}"
// slackSend(
// channel: channel,
// message: message,
// tokenCredentialId: 'slack-token'
// )
}

Разбор notifySlack

  • Сигнатура call(String message) — один аргумент, не Map.
  • echo — встроенный step Pipeline (безопасен в sandbox).
  • Закомментированный slackSend — реальный вызов после настройки Credentials и Slack plugin.
  • В standardPipeline вызывается как notifySlack("...") — имя файла без .groovy.

Классы в src/

Сложную логику (ветвления deploy, semver-check) выносят в классы — их проще тестировать Spock без Jenkins.

// src/com/example/ci/DeployHelper.groovy
package com.example.ci

class DeployHelper implements Serializable {
def steps

DeployHelper(steps) {
this.steps = steps
}

void deploy(String env) {
steps.sh "./deploy.sh ${env}"
}
}

Использование в global var:

import com.example.ci.DeployHelper

def call(Map config) {
pipeline {
agent any
stages {
stage('Deploy') {
steps {
script {
def helper = new DeployHelper(this)
helper.deploy(config.env ?: 'staging')
}
}
}
}
}
}

Зачем Serializable и script

  • CPS сериализует состояние pipeline между шагами; поля класса должны быть Serializable (или примитивы/String).
  • new DeployHelper(this) — в vars this ссылается на CpsScript с доступом к sh, echo.
  • Вызов steps { script { ... } } — Groovy-код внутри declarative stage (22.md § script block).

Антипаттерн — хранить в поле класса new File('/tmp') или JDBC connection — не Serializable, сборка упадёт с NotSerializableException.


resources/ и libraryResource

HTML-шаблон письма или JSON-schema:

def template = libraryResource('com/example/ci/email.html')
echo template.replace('{{BUILD}}', env.BUILD_NUMBER)

Файл лежит в resources/com/example/ci/email.html. Путь совпадает с package в src/.


Версионирование library

СтратегияПлюсыМинусы
@Library('lib@main')Всегда последний commitСломанный main ломает все jobs
@Library('lib@1.2.0')Semver, воспроизводимостьBump в каждом app при мажоре
@Library('lib@abc1234')Pin на commitНеудобно читать
Implicit + default mainКороткий JenkinsfileСкрытая версия

Рекомендация platform team

  1. Тег 1.2.0 на каждый релиз library.
  2. В app — @1.2.0 или @1 (если настроен retriever на refs/tags/*).
  3. Мажорный bump — миграционный guide в README library.

Trusted и untrusted

TrustedUntrusted
SandboxШире — доверенный код orgScript Security, whitelist
КогдаPrivate Git org, admin контролирует mergePR из fork, публичный contrib
РискКомпромисс repo = риск controllerНиже для untrusted pipeline

Библиотеки из корпоративного GitLab/GitHub обычно trusted. Для pull request from fork в Multibranch — pipeline untrusted, library может не подгрузиться — тестируйте library на staging controller.


load и @Library

def utils = load 'ci/utils.groovy'
utils.runTests()

Файл ci/utils.groovy в workspace app-репо (должен return this):

def runTests() {
sh './gradlew test'
}
return this
load@Library
ИсточникWorkspace текущей сборкиGit library repo
ВерсияCommit app-репоТег library
КонвенцииНетvars/, src/
МасштабОдин проектОрганизация

load — прототип; стандарт команды — Shared Library.


Сквозной поток

Job DSL может создать Multibranch job, который вызывает @Library — infra + library + короткий Jenkinsfile.


Тестирование library

  • Unit — классы из src/ через Gradle + Spock, без Jenkins (DeployHelperSpec.groovy).
  • Integration — test Jenkins + @Library('lib@PR-42') на staging.
  • Lint — CodeNarc / npm-groovy-lint в CI library-репо (101.md — рекомендации).

Global vars с полным pipeline { } на unit-тесты плохо ложатся — smoke-test job на staging.


Кейс раздела — pipeline каталога книг

После миграций БД и Spock-тестов:

@Library('company-pipeline-lib@1') _

standardPipeline(
testCommand: './gradlew test --no-daemon',
deploy: params.DEPLOY_CATALOG == 'true',
deployCommand: './scripts/flyway-migrate.sh && ./deploy-catalog.sh',
junitPattern: '**/build/test-results/test/*.xml'
)

Flyway в нескольких сервисах — вынесите в vars/flywayMigrate.groovy. Игра на FastJ — тот же standardPipeline с testCommand: './gradlew test' и опциональным runtime stage в library.


Частые ошибки

СимптомПричинаРешение
Library company-pipeline-lib not foundName не совпадает с Global LibrariesПроверить регистрацию, credentials Git
Required context class hudson.FilePathStep вне steps / scriptОбернуть в script { }
NotSerializableExceptionПоле класса не SerializableПримитивы, String, Serializable types
Старая версия library после tagКэш / неверный ref@Library('@1.2.1'), rebuild
No such property: callНет def call в varsДобавить call
Двойной @LibraryImplicit + explicitОтключить implicit или убрать дубль
notifySlack not foundФайл не в vars/ или опечаткаИмя файла = имя вызова

Что попробовать

  1. Repo jenkins-shared-library + vars/helloPipeline.groovy с call() { echo 'Hello' }.
  2. Test job с @Library('...') _ и helloPipeline().
  3. Расширить до standardPipeline с параметрами Map.
  4. DeployHelper в src/ + Spock-тест без Jenkins.
  5. Связать с Job DSL — DSL создаёт job, library наполняет pipeline.

Дальше

Job DSL Playground · Jenkins Pipeline · Gradle Groovy DSL · FastJ — сборка в CI