Jenkinsfile – это не Groovy
Сразу стоит отметить, что я не нашел в документации к Jenkins утверждения, что Jenkinsfile
пишется на Groovy.
В документации к Jenkins сказано:
Scripted Pipeline is a domain-specific language [3] based on Groovy, most Groovy syntax can be used in Scripted Pipeline without modification.
Но количество отсылок к Groovy столь велико, что у многих людей создаются ложные ожидания.
Я решил написать этот пост после многократного объяснения коллегам отличий скрипта Jenkinsfile
от Groovy.
Также важно отметить, что всё примеры проверялись на версии 2.414.3 (самый свежий LTS на момент написания статьи) и, возможно, ситуация изменится.
А что, собственно, не так?
Если оно выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, и есть утка.
Несмотря на то, что код Jenkinsfile
компилируется при помощи Groovy-компилятора, в дальнейшем он подвергается дополнительной обработке и перестаёт вести себя как Groovy-код.
То есть он выглядит как Groovy, компилируется как Groovy, но не ведёт себя как Groovy.
И речь не о том, что он выполняется в песочнице и часть библиотек не доступна. Всё гораздо хуже: некоторые базовые конструкции тихо меняют своё поведение.
Отличие поведения циклов
Все мы знаем, что Linux великолепен… Он выполняет бесконечные циклы за 5 секунд.
Linus Torvalds
В Jenkins тело обычного цикла for
без условий (классический бесконечный цикл) не выполняется ни разу.
Пример проблемного Jenkinsfile
(можно также выполнить в https://groovyide.com/playground):
def messages = []
messages += "Begin"
for (; ;) {
messages += "Inside classic loop"
break
}
messages += "End"
// Show result with little hack to use same code in groovy and Jenkinsfile
def echo = "echo" in this ? echo : { it -> println(it) }
echo(messages.join('\\n'))
Вывод в https://groovyide.com/playground:
Begin
Inside classic loop
End
Вывод в Jenkins:
[Pipeline] Start of Pipeline
[Pipeline] echo
Begin
End
[Pipeline] End of Pipeline
Нарушенная спецификация:
Странное поведение вызова методов
В Jenkins по-другому работает вызов методов в стиле обращения к свойствам объекта.
Пример проблемного Jenkinsfile
(можно также выполнить в https://groovyide.com/playground):
def messages = []
messages += "Begin"
// List 1
if ([]) {
messages += "List 1 is not empty (WTF)"
} else {
messages += "List 1 is empty (OK)"
}
// List 2
if (["a"]) {
messages += "List 2 is not empty (OK)"
} else {
messages += "List 2 is empty (WTF)"
}
// List 3
if ([].isEmpty()) {
messages += "List 3 is empty (OK)"
} else {
messages += "List 3 is not empty (WTF)"
}
// List 4
if (["a"].isEmpty()) {
messages += "List 4 is empty (WTF)"
} else {
messages += "List 4 is not empty (OK)"
}
// List 5
if ([].empty) {
messages += "List 5 is empty (OK)"
} else {
messages += "List 5 is not empty (WTF)"
}
// List 6
if (["a"].empty) {
messages += "List 6 is empty (WTF)"
} else {
messages += "List 6 is not empty (OK)"
}
messages += "End"
// Show result with little hack to use same code in groovy and Jenkinsfile
def echo = "echo" in this ? echo : { it -> println(it) }
echo(messages.join('\\n'))
Вывод в https://groovyide.com/playground:
Begin
List 1 is empty (OK)
List 2 is not empty (OK)
List 3 is empty (OK)
List 4 is not empty (OK)
List 5 is empty (OK)
List 6 is not empty (OK)
End
Вывод в Jenkins:
[Pipeline] Start of Pipeline
[Pipeline] echo
Begin
List 1 is empty (OK)
List 2 is not empty (OK)
List 3 is empty (OK)
List 4 is not empty (OK)
List 5 is not empty (WTF)
List 6 is empty (WTF)
End
[Pipeline] End of Pipeline
Нарушенная спецификация:
Пара слов о данных примерах
После данных примеров особо хотел бы отметить:
Это далеко не исчерпывающий список проблем. В реальности всё гораздо хуже, просто эти два примера очень легко воспроизвести.
Код в случае подобных проблем меняет своё поведение тихо, а не выплёвывает ошибку. Это добавляет отдельной остроты в поиске проблем.
Очень тяжело писать код, когда ни в какой строке нельзя быть уверенным.
Причем это касается не только самого Jenkinsfile
, но и кода в Jenkins Shared Libraries. Хотя с Jenkins Shared Library немного проще — там можно писать код который не обладает подобными эффектами.
Как им удалось этого добиться?
Причина изменения поведения кода — Continuation Passing Style (CPS) преобразование.
Jenkins преобразовывает уже скомпилированный в байт-код скрипт к виду, когда может выполнять его по шагам сохраняя внутренне состояние отдельно. На этапе этого преобразования некоторые конструкции меняют своё поведение.
Как жить?
Лучше всего избегать сложной логики в Jenkinsfile
, но это не всегда возможно.
К счастью, есть проект JenkinsPipelineUnit, который позволяет из Unit-тестов на настоящем Groovy выполнять код после CPS-преобразования.
Этот проект позволяет писать тесты на код, выполняемый в скриптах Jenkins, но я так и не смог найти красивое решение по организации тестируемого кода в Jenkinfile
.
Общий механизм написания тестируемого кода у меня получился примерно следующий:
весь тестируемый код оформляется в Jenkins Shared Libraries
на тестируемый код пишутся тесты с помощью JenkinsPipelineUnit
при использования этого кода в
Jenkinsfile
, для подключения Jenkins Shared Library из того же репозитория использую метод library
Об использовании Jenkins Shared Libraries в том же репозитории много написано на StackOverflow: https://stackoverflow.com/questions/46213913/load-jenkins-pipeline-shared-library-from-same-repository
Суть проблемы в том, что через @Library
нельзя сослаться на тот же коммит того же репозитория.
В результате приходится загружать библиотеку динамически кодом вида:
def lib = library(identifier: "local@latest", retriever: legacySCM(scm)).com.mycorp.pipeline
lib.Utils.someStaticMethod()
Если собрать всё это вместе, то оно работает, но результат выглядит так себе:
в
Jenkinsfile
можно обращаться к статическим методам, но нельзя сослаться на типы;код Jenkins Shared Libraries лежит вперемешку с основным кодом репозитория.
Посмотреть репозиторий с запусков тестов для Jenkins-скриптов можно здесь: https://github.com/bozaro/jenkins-testing
В целом, каждый раз, когда речь заходит о Jenkins, я вспоминаю цитату из твита: «Jenkins is currently the CI gold standard and it«s a very low bar».