Pipeline Jenkins

Groovy dalam pipeline deklaratif dan scripted Jenkins pada praktik CI.

Daftar isi

Jenkins mengeksekusi pekerjaan otomasi dengan instruksi yang ditulis dalam Groovy, dan wadah paling umumnya adalah pipeline, yaitu rangkaian tahap yang didefinisikan sebagai kode dan dijalankan berurutan. Topik ini membahas Jenkinsfile yang tersimpan di repository, sintaks deklaratif dan scripted, keterbatasan transformasi CPS terhadap closure, hingga Shared Library untuk kode bersama. Karena Jenkins tidak dijalankan di sandbox, contoh Jenkinsfile di sini ditampilkan untuk dibaca saja, sedangkan pola eksekusinya disimulasikan dengan skrip Groovy polos yang keluarannya asli.

Jenkinsfile sebagai pipeline as code#

Jenkinsfile adalah berkas teks yang memuat definisi satu pipeline dan disimpan dalam repository bersama kode proyeknya. Praktik ini disebut pipeline as code, dan dokumentasi resmi Jenkins menyebut tiga manfaat langsung dari praktik ini, yaitu tinjauan kode (code review) terhadap pipeline, jejak audit setiap perubahan, dan satu sumber kebenaran bagi beberapa anggota tim. Jenkins mendukung dua sintaks, deklaratif dan scripted, dan keduanya dapat ditulis di UI maupun di Jenkinsfile, dengan penyimpanan di repository sebagai praktik yang disarankan.

Isi Jenkinsfile memang Groovy. Jenkins menguraikannya dengan parser Groovy, lalu mengeksekusi pemanggilan khusus bernama step, yaitu operasi yang disediakan Jenkins seperti sh untuk perintah shell dan echo untuk mencetak pesan ke log. Sebagian besar step diakhiri blok closure, sehingga bentuk stage('Uji') { ... } sebenarnya adalah pemanggilan metode bernama stage dengan closure sebagai argumen terakhirnya. Setiap eksekusi pipeline menghasilkan satu build, yaitu satu kali pengerjaan penuh. Pengetahuan Groovy tentang closure dan perulangan tetap berlaku di sini, dengan catatan keterbatasan yang dibahas pada bagian CPS.

CPS dan keterbatasan eksekusinya#

Pipeline tidak menjalankan kode Groovy sebagaimana perintah groovy nama-berkas.groovy. Jenkins memakai library bernama Groovy CPS yang menafsirkan hampir seluruh program melalui transformasi continuation-passing style (CPS), yaitu penataan ulang program sehingga setiap pemanggilan meneruskan kelanjutan eksekusinya secara eksplisit. Tujuan transformasi ini adalah ketahanan, karena keadaan program disimpan ke disk pada setiap operasi asinkron, sehingga build yang sedang berjalan dapat dilanjutkan dari titik terakhirnya meskipun Jenkins dimulai ulang.

Aturan pemanggilannya ketat. Hampir seluruh skrip pipeline dan mayoritas step ditransformasi CPS, sedangkan kode Java hasil kompilasi, badan konstruktor, metode @NonCPS, dan step seketika seperti echo tidak mengalaminya. Kode CPS boleh memanggil kode non-CPS maupun CPS lain, sedangkan kode non-CPS tidak boleh memanggil kode CPS, dan pelanggarannya terdeteksi ketika pipeline berjalan sebagai peringatan berpola expected to call ... but wound up catching ....

Batas ini memengaruhi gaya penulisan, karena dokumentasi plugin Workflow Groovy menyarankan agar logika build yang berat didelegasikan ke program eksternal melalui sh atau bat dan tidak ditulis panjang di dalam pipeline. Contoh resminya pun menulis perulangan dengan for biasa alih-alih metode koleksi seperti each agar lebih banyak kode berada di sisi yang aman. Konstruksi yang tidak didukung ditolak dengan peringatan.

Pipeline deklaratif#

Pipeline deklaratif membungkus seluruh pekerjaan dalam satu blok pipeline {} dengan struktur tetap. Direktif agent any menugaskan build ke agent apa pun yang tersedia, yaitu mesin tempat langkah dijalankan. Rangkaian pekerjaan disusun di dalam stages, setiap stage mewakili satu tahap bernama, sedangkan perintah nyata ditulis di dalam steps. Direktif pelengkap mengurus sisanya: environment untuk variabel lingkungan, when untuk kondisi eksekusi satu stage, options untuk perilaku build, dan post untuk langkah penutup menurut status akhir, misalnya always, success, dan failure.

Opsi disableConcurrentBuilds() melarang dua build pipeline yang sama berjalan bersamaan, sehingga build berikutnya menunggu di antrean. Hal ini penting ketika langkah menulis ke berkas atau folder bersama. Opsi timeout membatalkan build yang melewati batas waktunya, misalnya 15 menit, agar langkah yang menggantung tidak memakan sumber daya Jenkins tanpa akhir.

Jenkinsfile berikut memakai pustaka pengolah statistik ujian sebagai contoh proyeknya, dengan tahap pengujian dan arsip cadangan.

groovy Jenkinsfile
// tidak-dijalankan
pipeline {
    agent any
    options {
        disableConcurrentBuilds()
        timeout(time: 15, unit: 'MINUTES')
    }
    environment {
        NAMA_PUSTAKA = 'statistik-ujian'
    }
    stages {
        stage('Uji') {
            steps {
                sh 'groovy uji-statistik.groovy'
            }
        }
        stage('Arsip') {
            when {
                branch 'utama'
            }
            steps {
                sh "cp laporan/hasil-uji.txt arsip/${BUILD_NUMBER}-hasil-uji.txt"
                archiveArtifacts artifacts: 'arsip/*.txt', fingerprint: true
            }
        }
    }
    post {
        always {
            echo "Build ${BUILD_NUMBER} untuk ${NAMA_PUSTAKA} selesai dengan status ${currentBuild.currentResult}"
        }
        failure {
            echo 'Periksa log stage Uji untuk melihat penyebab kegagalannya.'
        }
    }
}

Stage Arsip hanya dieksekusi pada branch utama karena kondisi when, lalu menyalin laporan hasil uji ke folder arsip bernomor build dan menyimpannya sebagai artefak melalui archiveArtifacts. Blok post mencetak status pada setiap akhir build ditambah pesan khusus pada kondisi failure. Mekanisme stage('Uji') { ... } seperti itu dapat ditiru dengan fungsi Groovy polos yang menerima closure sebagai langkahnya.

groovy Contoh: stage tiruan yang menerima closure
// stage tiruan: mengeksekusi closure yang diterimanya
def stage(String nama, Closure isi) {
    println "[MULAI]   ${nama}"
    isi()
    println "[SELESAI] ${nama}"
}

stage('Uji') {
    println "menjalankan 12 kasus uji statistik"
}
text Keluaran
[MULAI]   Uji
menjalankan 12 kasus uji statistik
[SELESAI] Uji

Panggilan stage('Uji') { ... } menempatkan closure itu sebagai argumen kedua, dan isi() di dalam fungsinya adalah tempat isi langkah sungguhan dijalankan.

Pipeline scripted#

Pipeline scripted menulis pekerjaan langsung sebagai kode Groovy di dalam blok node {}. Bentuk ini lebih tua dari deklaratif dan tidak memaksakan struktur, sehingga def, for, if, blok try/catch, dan fungsi buatan sendiri dipakai sebagaimana skrip Groovy biasa. Langkah stage tetap tersedia untuk memberi kelompok bernama pada log, tetapi Jenkins tidak memvalidasi susunannya. Contoh berikut menangkap kegagalan pengujian secara manual.

groovy Contoh: pipeline scripted dengan node dan try/catch
// tidak-dijalankan
node {
    stage('Uji') {
        try {
            sh 'groovy uji-statistik.groovy'
        } catch (e) {
            echo "Uji gagal: ${e.message}"
            throw e
        }
    }
}

Blok try/catch menangkap exception dari langkah sh, mencatat penyebabnya melalui echo, lalu melemparnya kembali dengan throw agar status build tetap gagal. Fleksibilitas ini memudahkan pola rumit, tetapi kebebasan bentuk membuat alurnya sulit ditebak saat dibaca orang lain, sedangkan deklaratif memberi kerangka seragam antarproyek. Keduanya sah dan dapat dicampur dalam satu Jenkinsfile. Tabel berikut merangkum perbedaannya.

AspekDeklaratifScripted
Blok pembukapipeline {} dengan struktur direktif tetapnode {} berisi kode Groovy bebas
Susunan tahapwajib di dalam stages dan stepsbebas, hanya diberi label stage
Penanganan errorkondisi failure pada posttry/catch Groovy biasa
Logika kompleksdibatasi direktif yang tersediapenuh, sepanjang CPS mengizinkannya
Pemakaian lazimmayoritas pipeline proyek barukasus yang membutuhkan kontrol alur khusus

Shared Library#

Pipeline yang dipakai banyak proyek memunculkan pola yang sama berulang kali, dan Shared Library menampungnya di repository terpisah yang dapat dimuat pipeline mana pun. Struktur dasarnya berupa dua folder. Folder src memuat kelas Groovy dengan susunan direktori bergaya Java, dan folder itu ditambahkan ke classpath saat pipeline dieksekusi. Folder vars berisi skrip yang tersedia sebagai variabel global, dengan nama berkasnya menjadi nama variabel, sehingga vars/hitungRerata.groovy dapat dipanggil sebagai hitungRerata() dari Jenkinsfile. Nama berkasnya harus berupa identifier Groovy yang sah, dan berkas .txt bernama sama berisi dokumentasinya.

text Struktur dasar Shared Library
(root)
+- src
|  +- org
|     +- statistik
|        +- StatistikUjian.groovy
+- vars
   +- hitungRerata.groovy
   +- hitungRerata.txt

Untuk pustaka pengolah statistik ujian, perhitungan murni seperti rerata dan simpangan disimpan sebagai kelas di src sehingga dapat diuji tanpa Jenkins, sedangkan pembungkusnya berupa langkah siap pakai di vars. Seluruh kode dalam Shared Library tetap tunduk pada CPS karena dieksekusi oleh interpreter yang sama dengan skrip pipeline, sehingga aturan pemanggilan pada bagian sebelumnya berlaku juga di sini. Versi Shared Library dan cara pemuatannya dikonfigurasi di sisi Jenkins, bukan di dalam Jenkinsfile.

Simulasi pola stages tanpa Jenkins#

Pola inti pipeline dapat diuji di terminal tanpa Jenkins, yaitu kumpulan tahap yang disimpan sebagai closure dan dieksekusi berurutan dengan for. Skrip berikut membentuk map bernama stages yang setiap entrinya berupa closure tanpa parameter { -> }, lalu perulangan mencetak status [MULAI] dan [SELESAI] di sekeliling pemanggilannya. Perulangan for dipakai sesuai anjuran CPS karena bentuk polosnya tidak menimbulkan ketidakcocokan pemanggilan antara kode non-CPS dan langkah pipeline.

groovy simulasi-stage.groovy
// simulasi-stage.groovy: pola stages dengan map closure dan for
def stages = [
    uji: { ->
        println "menjalankan 12 kasus uji"
        return 'lulus'
    },
    hitung: { ->
        println "menghitung statistik nilai"
        return 'rerata 78,5'
    },
    arsip: { ->
        println "menyalin laporan ke arsip cadangan"
        return '2 berkas'
    },
]

def namaPustaka = 'statistik-ujian'
for (nama in stages.keySet()) {
    println "[MULAI]   ${nama} (${namaPustaka})"
    def hasil = stages[nama]()
    println "[SELESAI] ${nama}: ${hasil}"
}
println "Pipeline simulasi selesai"
text Keluaran
[MULAI]   uji (statistik-ujian)
menjalankan 12 kasus uji
[SELESAI] uji: lulus
[MULAI]   hitung (statistik-ujian)
menghitung statistik nilai
[SELESAI] hitung: rerata 78,5
[MULAI]   arsip (statistik-ujian)
menyalin laporan ke arsip cadangan
[SELESAI] arsip: 2 berkas
Pipeline simulasi selesai

Urutan keluaran mengikuti urutan entri map, dan setiap tahap melaporkan hasil kembaliannya, sehingga pola status MULAI dan SELESAI terlihat utuh. Menambahkan tahap baru berarti menambah satu entri map saja tanpa menyentuh badan perulangannya, dan perubahan semacam itulah yang dilakukan sehari-hari pada stages pipeline sungguhan. Bila salah satu closure melempar exception, skrip berhenti pada tahap itu, mirip pipeline yang berhenti pada stage gagalnya. Menambahkan blok try/catch di dalam badan perulangan meniru perilaku post { always { ... } } pada deklaratif, yaitu pembersihan yang tetap dijalankan sesudah kegagalan.

Kesalahan umum#

Kesalahan pertama adalah memanggil langkah dari metode yang diberi anotasi @NonCPS. Metode semacam itu dikompilasi dengan semantik Groovy normal agar bebas dari transformasi CPS, sehingga tidak boleh menyentuh kode CPS, termasuk langkah node dan sh. Halaman CPS Method Mismatches memuat contoh yang hampir identik, dan peringatan inilah yang muncul di log Jenkins.

groovy Contoh: langkah node dipanggil dari metode @NonCPS
// tidak-dijalankan
@NonCPS
def kumpulkanPlatform() {
    ['linux', 'windows'].each { platform ->
        node(platform) {
            sh 'make'
        }
    }
}
text Keluaran yang diharapkan
expected to call WorkflowScript.kumpulkanPlatform but wound up catching node

Perbaikannya sesederhana menghapus anotasi @NonCPS, karena metode yang memanggil langkah memang membutuhkan eksekusi CPS. Pesan itu bukan error fatal, tetapi perilaku metodenya menjadi anomali.

Kesalahan kedua adalah menaruh closure di dalam GString dengan harapan closure itu dievaluasi setiap kali string dipakai. Pada skrip Groovy biasa pola ini sah, sedangkan pada pipeline closure di dalam GString ikut ditransformasi CPS, sehingga pemanggilannya tidak sesuai harapan dan muncul peringatan yang menunjuk kelas CpsClosure2.

groovy Contoh: closure di dalam GString
// tidak-dijalankan
def x = 1
def s = "x = ${-> x}"
x = 2
echo s
text Keluaran yang diharapkan
expected to call WorkflowScript.echo but wound up catching org.jenkinsci.plugins.workflow.cps.CpsClosure2.call

Perbaikannya membalik strukturnya, yaitu def s = { -> "x = ${x}" } dengan ekspresi biasa di dalam GString, lalu closure itu dipanggil sebagai s() di tempat string semula dipakai.

Ringkasan#

  • Jenkinsfile menyimpan definisi pipeline di repository sehingga otomasi mendapat tinjauan kode, jejak audit, dan satu sumber kebenaran.
  • Jenkins mengeksekusi pipeline melalui transformasi CPS yang menyimpan status program ke disk, dan kode non-CPS seperti metode @NonCPS tidak boleh memanggil langkah.
  • Pipeline deklaratif menetapkan struktur pipeline, stages, steps, dan post, sedangkan pipeline scripted menulis Groovy bebas di dalam node yang lebih fleksibel tetapi kurang seragam.
  • Shared Library membagi kode lewat folder src untuk kelas dan vars untuk langkah global, sedangkan pola stages dapat disimulasikan dengan map closure yang dieksekusi for berurutan.

Referensi#