Jenkins

Примеры Pipeline и базовый синтаксис Groovy.


docker run -d --name=jenkins -p 8080:8080 --restart=always -v jenkins_home:/var/jenkins_home jenkins/jenkins:latest
ls /var/lib/docker/volumes/jenkins_home/_data/jobs директория хранящая историю сборок в хостовой системе
docker exec -u root -it jenkins /bin/bash подключиться к контейнеру под root
cat /var/jenkins_home/secrets/initialAdminPassword получить токен инициализации
apt-get update && apt-get install -y iputils-ping netcat-openbsd установить ping и nc на машину сборщика (master slave)

jenkinsVolumePath=$(docker inspect jenkins | jq -r .[].Mounts.[].Source) получить путь к директории Jenkins в хостовой системе
sudo tar -czf $HOME/jenkins-backup.tar.gz -C $jenkinsVolumePath . резервная копия всех файлов
(crontab -l ; echo "0 23 * * * sudo tar -czf /home/lifailon/jenkins-backup.tar.gz -C /var/lib/docker/volumes/jenkins_home/_data .") | crontab -
sudo tar -xzf $HOME/jenkins-backup.tar.gz -C /var/lib/docker/volumes/jenkins_home/_data восстановление

wget http://127.0.0.1:8080/jnlpJars/jenkins-cli.jar -P $HOME/ скачать jenkins-cli (http://127.0.0.1:8080/manage/cli)
apt install openjdk-17-jre-headless установить java runtime
java -jar jenkins-cli.jar -auth lifailon:password -s http://127.0.0.1:8080 -webSocket help получить список команд
java -jar jenkins-cli.jar -auth lifailon:password -s http://127.0.0.1:8080 groovysh запустить консоль Groovy
java -jar jenkins-cli.jar -auth lifailon:password -s http://127.0.0.1:8080 install-plugin ssh-steps -deploy устанавливаем плагин SSH Pipeline Steps

API

$username = "Lifailon"
$password = "password"
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $username,$password)))
$headers = @{Authorization=("Basic {0}" -f $base64AuthInfo)}
Invoke-RestMethod "http://192.168.3.101:8080/rssAll" -Headers $headers # RSS лента всех сборок и их статус в title
Invoke-RestMethod "http://192.168.3.101:8080/rssFailed" -Headers $headers # RSS лента всех неудачных сборок

$jobs = Invoke-RestMethod "http://192.168.3.101:8080/api/json/job" -Headers $headers
$jobs.jobs.name # список всех проектов
$jobName = "Update SSH authorized_keys"
$job = Invoke-RestMethod "http://192.168.3.101:8080/job/${jobName}/api/json" -Headers $headers
$job.builds # список всех сборок
$buildNumber = $job.lastUnsuccessfulBuild.number # последняя неуспешная сборка
Invoke-RestMethod "http://192.168.3.101:8080/job/${jobName}/${buildNumber}/consoleText" -Headers $headers # вывести лог указанной сборки

$lastCompletedBuild = $job.lastCompletedBuild.number # последняя успешная сборка
$crumb = $(Invoke-RestMethod "http://192.168.3.101:8080/crumbIssuer/api/json" -Headers $headers).crumb # получаем временный токен доступа (crumb)
$headers["Jenkins-Crumb"] = $crumb # добавляем crumb в заголовки
$body = @{".crumb" = $crumb} # добавляем crumb в тело запроса
Invoke-RestMethod "http://192.168.3.101:8080/job/${jobName}/${lastCompletedBuild}/rebuild" -Headers $headers -Method POST -Body $body # перезапустить сборку

SSH Steps and Artifacts

Устанавливаем плагин SSH Pipeline Steps

Добавляем логин и Private Key для авторизации по ssh: Manage (Settings) => Credentials => Global => Add credentials => Kind: SSH Username with private key

Сценарий проверяет доступность удаленной машины, подключается к ней по ssh, выполняет скрипт hwstat для сбора метрик и выгружает json отчет в артефакты:

// Глобальный массив для хранения данных подключения по ssh 
def remote = [:]

pipeline {
    agent any
    parameters {
        string(name: 'address', defaultValue: '192.168.3.101', description: 'Адрес удаленного сервера')
        // choice(name: "addresses", choices: ["192.168.3.101","192.168.3.102"], description: "Выберите сервер из выпадающего списка")
        string(name: 'port', defaultValue: '22', description: 'Порт ssh')
        string(name: 'credentials', defaultValue: 'd5da50fc-5a98-44c4-8c55-d009081a861a', description: 'Идентификатор учетных данных из Jenkins')
        booleanParam(name: "root", defaultValue: false, description: 'Запуск с повышенными привилегиями')
        booleanParam(name: "report", defaultValue: true, description: 'Выгружать отчет в формате json')
    }
    triggers {
        cron('H */6 * * 1-5') // выполнять запуск каждын 6 часов с понедельника по пятницу
    }
    options {
        timeout(time: 5, unit: 'MINUTES') // период ожидания, после которого нужно прервать Pipeline
        retry(2) // в случае неудачи повторить весь Pipeline указанное количество раз
    }
    environment {
        // Переменная окружения для хранения пути временного файла с содержимым приватного ключа
        SSH_KEY_FILE = "/tmp/ssh_key_${UUID.randomUUID().toString()}"
    }
    stages {
        stage('Проверка доступности хоста (icmp и tcp)') {
            steps {
                script {
                    def check = sh(
                        script: """
                            ping -c 1 ${params.address} > /dev/null || exit 1
                            nc -z ${params.address} ${params.port} || exit 2
                        """,
                        returnStatus: true // исключить завершение Pipeline с ошибкой
                    )
                    if (check == 1) {
                        error("Сервер ${params.address} недоступен (icmp ping)")
                    } else if (check == 2) {
                        error("Порт ${params.address} закрыт (tcp check)")
                    } else {
                        echo "Сервер ${params.address} доступен и порт ${params.port} открыт"
                    }
                }
            }
        }
        stage('Извлечение данных для авторизации по ключу') {
            steps {
                script {
                    withCredentials([sshUserPrivateKey(credentialsId: params.credentials, usernameVariable: 'SSH_USER', keyFileVariable: 'SSH_KEY', passphraseVariable: '')]) {
                        // Записываем содержимое приватного ключа во временный файл
                        writeFile(file: env.SSH_KEY_FILE, text: readFile(SSH_KEY))
                        sh "chmod 600 ${env.SSH_KEY_FILE}"
                        remote.name = params.address
                        remote.host = params.address
                        remote.port = params.port.toInteger()
                        remote.user = SSH_USER
                        remote.identityFile = env.SSH_KEY_FILE
                        remote.allowAnyHosts = true
                    }
                }
            }
        }
        stage('Запуск скрипта через через ssh') {
            steps {
                script {
                    def runCommand
                    if (params.root) {
                        runCommand = """
                            curl -sS https://raw.githubusercontent.com/Lifailon/hwstat/rsa/hwstat.sh | sudo bash -s -- "json" > "hwstat-report.json"
                        """
                    } else {
                        runCommand = """
                            curl -sS https://raw.githubusercontent.com/Lifailon/hwstat/rsa/hwstat.sh | bash -s -- "json" > "hwstat-report.json"
                        """
                    }
                    def jsonOutput = sshCommand remote: remote, command: runCommand
                    if (params.report) {
                        // Записать содержимое переменной в файл
                        // writeFile file: 'hwstat-report.json', text: jsonOutput
                        // Загрузить файл из удаленной машины
                        sshGet remote: remote, from: "hwstat-report.json", into: "${env.WORKSPACE}/hwstat-report.json", override: true
                    }
                    sshCommand remote: remote, command: "rm hwstat-report.json"
                }
            }
        }
        stage('Загрузка json отчета в Jenkins') {
            // Проверка условия перед выполнением шага (пропуск если false)
            when {
                expression { params.report }
            }
            steps {
                archiveArtifacts artifacts: 'hwstat-report.json', allowEmptyArchive: true
            }
        }
    }
    post {
        // Выполнять независимо от успеха или ошибки
        always {
            script {
                sh "rm -f ${env.SSH_KEY_FILE}"
            }
        }
        success   { echo "Сборка завершена успешно" }
        failure   { echo "Сборка завершилась с ошибкой" }
        unstable  { echo "Сборка завершилась с предупреждениями" }
        changed   { echo "Текущий статус завершения изменился по сравнению с предыдущим запуском" }
        fixed     { echo "Сборка завершена успешно по сравнению с предыдущим запуском" }
        aborted   { echo "Запуск был прерван" }
    }
}

Update SSH authorized_keys

Добавляем логин и пароль для авторизации по ssh: Manage (Settings) => Credentials => Global => Add credentials => Kind: Username with password

Сценарий обновляет параметр со списком текущих пользователей на машине и добавляет или заменяет ssh ключ для выбранного пользователя:

def remote = [:]

pipeline {
    agent any
    parameters {
        string(name: 'address', defaultValue: '192.168.3.101', description: 'Адрес удаленного сервера')
        string(name: 'port', defaultValue: '22', description: 'Порт ssh')
        string(name: 'credentials', defaultValue: '15d05be6-682a-472b-9c1d-cf5080e98170', description: 'Идентификатор учетных данных из Jenkins')
        booleanParam(name: "getUsers", defaultValue: true, description: 'Получить список текущих пользователей системы')
        string(name: 'sshKey', defaultValue: '', description: 'Открытый ssh ключ для добавления в authorized_keys')
        booleanParam(name: "rewriteKey", defaultValue: false, description: 'Перезаписать текущие ключи в файле authorized_keys')
    }
    stages {
        stage('Извлекаем параметры для авторизации по ssh') {
            steps {
                script {
                    withCredentials([usernamePassword(credentialsId: params.credentials, usernameVariable: 'SSH_USER', passwordVariable: 'SSH_PASS')]) {
                        remote.name = params.address
                        remote.host = params.address
                        remote.port = params.port.toInteger()
                        remote.user = env.SSH_USER
                        remote.password = env.SSH_PASS
                        remote.allowAnyHosts = true
                    }
                }
            }
        }
        stage('Обновить список пользователей') {
            when {
                expression { params.getUsers }
            }
            steps {
                script {
                    def mainCommand = "echo \$(ls /home)"
                    def users = sshCommand remote: remote, command: mainCommand
                    def usersList = users.trim().split("\\s")
                    usersList += 'root'
                    def usersListChoice = usersList.toList()
                    writeFile file: 'user_list.txt', text: usersList.join("\\s")
                    properties([
                        parameters([
                            string(name: 'address', defaultValue: params.address, description: 'Адрес удаленного сервера'),
                            string(name: 'port', defaultValue: params.port, description: 'Порт ssh'),
                            string(name: 'credentials', defaultValue: params.credentials, description: 'Идентификатор учетных данных из Jenkins'),
                            booleanParam(name: "getUsers", defaultValue: params.getUsers, description: 'Получить список текущих пользователей системы'),
                            string(name: 'sshKey', defaultValue: '', description: 'Открытый ssh ключ для добавления в authorized_keys'),
                            booleanParam(name: "rewriteKey", defaultValue: false, description: 'Перезаписать текущие ключи в файле authorized_keys'),
                            choice(
                                name: 'userList',
                                choices: usersListChoice,
                                description: 'Выбрать пользователя'
                            )
                        ])
                    ])
                }
            }
        }
        stage('Добавить новый SSH ключ') {
            when {
                expression { !params.getUsers && params.sshKey }
            }
            steps {
                script {
                    def selectedUser = params.userList
                    def sshKey = params.sshKey
                    if (selectedUser == "root") {
                        path = "/root/.ssh/authorized_keys"
                    } else {
                        path= "/home/${selectedUser}/.ssh/authorized_keys"
                    }
                    if (params.rewriteKey) {
                        echo "Обновляем все SSH ключи для пользователя: ${selectedUser}"
                        teeCommand = "tee"
                    } else {
                        echo "Добавляем новый SSH ключ для пользователя: ${selectedUser}"
                        teeCommand = "tee -a"
                    }
                    def mainCommand = """
                        checkFile=\$(ls $path 2> /dev/null || echo false)
                        if [ \$checkFile == "false" ]; then
                            mkdir -p \$(dirname $path) && touch $path
                        fi
                        echo $sshKey | $teeCommand $path > /dev/null
                    """
                    sshCommand remote: remote, command: mainCommand
                }
            }
        }
    }
}

Upload File Parameter

Установить плагин File Parameter и перезагрузить Jenkins для использования нового параметра.

Передача файла через параметр и чтение его содержимого:

pipeline {
    agent any
    parameters {
        base64File 'UPLOAD_FILE'
    }
    stages {
        stage('Читаем содержимое файла') {
            steps {
                // Переменная хранит содержимое файла в формате base64
                // echo UPLOAD_FILE
                // Декодируем base64
                withFileParameter('UPLOAD_FILE') {
                    sh """
                        echo "$UPLOAD_FILE" # выводим путь к временному файлу с содержимым переданного файла
                        cat "$UPLOAD_FILE"  # читаем содержимое файла
                    """
                }
            }
        }
    }
}

Input Text and File

Останавливает выполнение Pipeline и заставляет пользователя передать текстовый параметр и файл:

pipeline {
    agent any
    stages {
        stage('Input text') {
            input {
                message "Что бы продолжить, передайте текст в поле ввода"
                ok "Передать"
                parameters {
                    string(name: 'TEXT', defaultValue: 'test', description: 'Введите текст')
                }
            }
            steps {
                echo "Переданный текст в параметре input: ${TEXT}"
            }
        }
        stage('Input file') {
            steps {
                script {
                    def fileBase64 = input message: "Передайте файл", parameters: [base64File('file')]
                    sh "echo $fileBase64 | base64 -d"
                }
            }
        }
    }
}

HttpURLConnection

Любой код Groovy возможно запустить и проверить через Script Console (http://127.0.0.1:8080/manage/script)

Пример API запроса к репозиторию PowerShell на GitHub для получения последней версии и всех доступных версий:

import groovy.json.JsonSlurper
def url = new URL("https://api.github.com/repos/PowerShell/PowerShell/tags")
def connection = url.openConnection()
connection.setRequestMethod("GET")
connection.setRequestProperty("Accept", "application/json")
def responseCode = connection.getResponseCode()
if (responseCode == 200) {
    // Получаем данные ответа
    def response = connection.getInputStream().getText()
    // Парсим JSON ответ
    def jsonSlurper = new JsonSlurper()
    def tags = jsonSlurper.parseText(response)
    // Проверяем количество элементов в массиве
    if (tags.size() > 0) {
        // Забираем последний тег из списка
        def latestTag = tags[0].name
        println("Latest version: " + latestTag)
        println()
        // Проходимся по всем тегам
        println("List of all versions:")
        for (tag in tags) {
            println(tag.name)
        }
    } else {
        error("No tags found in the response")
    }
} else {
    error("Failed to call API, response code: ${responseCode}")
}
connection.disconnect()

Active Choices Parameter

Плагин Active Choices позволяет динамически обновлять содержимое параметров.

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

Name: Repos

Groovy Script:

return [
    'Lifailon/lazyjournal',
    'jesseduffield/lazydocker'
]

Name: Versions

Groovy Script:

import groovy.json.JsonSlurper
def selectedRepo = Repos
def apiUrl = "https://api.github.com/repos/${selectedRepo}/tags"
def conn = new URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Jenkins")
def response = conn.getInputStream().getText()
def json = new JsonSlurper().parseText(response)
def versionsCount = json.size()
def data = []
for (int i = 0; i < versionsCount; i++) {
    data += json.name[i]
}
return data

Настройки параметров: Choice Type: Single Select Привязать параметр Repos из Active Choices в Reactive Parameter через Referenced parameters Включить фильтрацию через Enable filters

Name: Files

Groovy Script:

import groovy.json.JsonSlurper
def selectedRepo = Repos
def selectedVer = Versions
def apiUrl = "https://api.github.com/repos/${selectedRepo}/releases/tags/${selectedVer}"
def conn = new URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Jenkins")
def response = conn.getInputStream().getText()
def json = new JsonSlurper().parseText(response)
def data = []
for (file in json.assets) {
    data += file.name
}
return data

Referenced parameters: Repos,Versions

Pipeline script:

pipeline {
    agent any
    stages {
        stage('Selected parameters') {
            steps {
                script {
                    echo "Selected repository: https://github.com/${params.Repos}"
                    echo "Selected version: ${params.Versions}"
                    echo "Selected file: ${params.Files}"
                    echo "Url for download: https://github.com/${params.Repos}/releases/download/${params.Versions}/${params.Files}"
                }
            }
        }
    }
}

Vault

Интеграция HashiCorp Vault в Jenkins Pipeline через REST API для получения содержимого секретов и использовая в последующих стадиях/этапах сборки:

def getVaultSecrets(
    String address,
    String path,
    String token
) {
    def url = new URL("${address}/${path}")
    
    def connection = url.openConnection()
    connection.setRequestMethod("GET")
    connection.setRequestProperty("X-Vault-Token", token)
    connection.setRequestProperty("Accept", "application/json")
    
    def response = new groovy.json.JsonSlurper().parse(connection.inputStream)
    def user = response.data.data.user
    def password = response.data.data.password
    return [
        user: user,
        password: password
    ]
}

def USER_NAME
def USER_PASS

pipeline {
    agent any
    parameters {
        string(name: 'url', defaultValue: 'http://192.168.3.101:8200', description: 'Url адресс хранилища секретов')
        string(name: 'path', defaultValue: 'v1/kv/data/ssh-auth', description: 'Путь для извлечения секретов')
        password(name: 'token', defaultValue: 'hvs.bySybhyYOxSWEVk4FQDdcyyg', description: 'Токен доступа к API HashiCorp Vault')
    }
    stages {
        stage('Get vault secrets') {
            steps {
                script {
                    def secrets = getVaultSecrets(
                        "${params.url}",
                        "${params.path}",
                        "${params.token}"
                    )
                    USER_NAME = secrets.user
                    USER_PASS = secrets.password
                }
            }
        }
        stage('Use secrets') {
            steps {
                script {
                    echo "User: ${USER_NAME}"
                    echo "Password: ${USER_PASS}"
                }
            }
        }
    }
}

Email Extension

Установить расширение Email Extension для отправки на почту и настроить SMTP сервер в настройках Jenkins (System => Extended E-mail Notification)

SMTP server: smtp.yandex.ru SMTP port: 587 Credentials: Username with password (username@yandex.ru и app-password) Use TLS Default Content Type: HTML (text/html)

Настройка логирования в System Log: emailDebug + фильтр hudson.plugins.emailext и уровень ALL

pipeline {
    agent any
    parameters {
        string(name: 'emailTo', defaultValue: 'test@yandex.ru', description: 'Почтовый адрес назначения')
    }
    stages {
        stage('Вывод всех переменных окружения') {
            steps {
                script {
                    env.getEnvironment().each { key, value ->
                        echo "${key} = ${value}"
                    }
                }
            }
        }
        stage('Отправка на почту') {
            options {
                timeout(time: 1, unit: 'MINUTES')
            }
            steps {
                script {
                    emailext (
                        to:	"${params.emailTo}",
                        subject: "${env.JOB_NAME} - ${BUILD_NUMBER}",
                        mimeType: "text/html",
                        body: """
                            <html>
                                <body>
                                    <div style="padding-left: 30px; padding-bottom: 15px;" color="blue">
                                    <font name="Arial" color="#906090" size="3" font-weight="normal">
                                        <b> ${env.JOB_NAME} - ${env.BUILD_NUMBER} </b>
                                    </font>
                                    <br>
                                    <div style="padding-left: 30px; padding-bottom: 15px;" color="black">
                                    <br>
                                    <font name="Arial" color="black" size="2" font-weight="normal"> 
                                        <pre> Build url: ${env.BUILD_URL} </pre>
                                    </font>	
                                </body>
                            </html>
                        """
                    )
                }
            }
        }
    }
}

Parallel

pipeline {
    agent any
    stages {
        stage('Parallel sleeps') {
            parallel {
                stage('Task 1') {
                    steps {
                        script {
                            def currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Начало задачи 1"
                            sh 'sleep 10'
                            currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Завершение задачи 1"
                        }
                    }
                }
                stage('Task 2') {
                    steps {
                        script {
                            def currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Начало задачи 2"
                            sh 'sleep 5'
                            currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Завершение задачи 2"
                        }
                    }
                }
            }
        }
        stage('Parallel tasks via loop') {
            steps {
                script {
                    // Массив, где ключи содержит имя задачи, а значение содержит блоки кода для выполнения
                    def tasks = [:]
                    def taskNames = ['Task 1', 'Task 2', 'Task 3']
                    taskNames.each { taskName ->
                        tasks[taskName] = {
                            def currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Начало задачи: $taskName"
                            sh "sleep ${taskName == 'Task 1' ? 10 : taskName == 'Task 2' ? 5 : 3}"
                            currentTime = new Date().format('yyyy-MM-dd HH:mm:ss')
                            echo "[${currentTime}] Завершение задачи: $taskName"
                        }
                    }
                    parallel tasks
                }
            }
        }
    }
}

Groovy

Базовый синтаксис языка Groovy

// Переменные
javaString = 'java'
javaString
println javaString
javaString.class    // class java.lang.String
println 100.class   // class java.lang.Integer
j = '${javaString}' // не принимает переменные в одинарных кавычках
groovyString = "${javaString}"
bigGroovyString = """
    ${javaString}
    ${j}
    ${groovyString}
    ${2 + 2}
"""

// java
// ${javaString}
// java
// 4

a = "a"   // a
a + "123" // a123
a * 5     // aaaaa

// Массивы и списки
list =[1,2,3]
list[0]    // 1
list[0..1] // [1, 2]
range = "0123456789"
range[1..5] // 12345
map = [key1: true, key2: false]
map["key1"] // true
server = [:]
server.ip = "192.168.3.1"
server.port = 22
println(server) // [ip:192.168.3.1, port:22]

// Функции
def sum(a,b) {
    println a+b
}
sum(2,2) // 4

// Условия
def diff(x) {
    if (x < 10) {
        println("${x} < 10")
    } else if (x == 10) {
        println("${x} = 10")
    } else {
        println("${x} > 10")
    }
}
diff(11) // 11 > 10

// Циклы
list.each { l ->
    print l
}
// 123

for (i in 0..5) { 
    print i
}
// 012345

for (int i = 0; i < 10; i++) {
    print i
}
// 0123456789

i = 0
while (i < 3) {
    println(i)
    i++
}
// 0
// 1
// 2