スクリプトパイプラインでマトリクスビルドを行う方法


※ この記事はJenkins Community Blog 2019年12月2日 Sam Gleske投稿の記事「Matrix building in scripted pipeline」(CC BY-SA 4.0)の翻訳です。

マトリクスビルドに関する最近の発表のとおり、宣言型パイプラインでマトリクスビルドを実行できます。しかし、スクリプトパイプラインを使わなければならない場合のために、この記事では、スクリプトパイプラインでプラットフォームとツールのマトリクスビルドを行う方法を説明します。記事中のサンプルは、宣言型パイプラインのマトリクスのサンプルに倣っています。

スクリプトパイプラインでのマトリクスビルド

次のJenkinsスクリプトパイプラインは、2つのマトリクス軸の組み合わせをビルドします。マトリクスに軸を追加するのは Map matrix_axesにエントリを追加するだけなので、とても簡単に実現できます。

Jenkinsfile
// you can add more axes and this will still work
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
    BROWSER: ['firefox', 'chrome', 'safari', 'edge']
]

@NonCPS
List getMatrixAxes(Map matrix_axes) {
    List axes = []
    matrix_axes.each { axis, values ->
        List axisList = []
        values.each { value ->
            axisList << [(axis): value]
        }
        axes << axisList
    }
    // calculate cartesian product
    axes.combinations()*.sum()
}

// filter the matrix axes since
// Safari is not available on Linux and
// Edge is only available on Windows
List axes = getMatrixAxes(matrix_axes).findAll { axis ->
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

// parallel task map
Map tasks = [failFast: false]

for(int i = 0; i < axes.size(); i++) {
    // convert the Axis into valid values for withEnv step
    Map axis = axes[i]
    List axisEnv = axis.collect { k, v ->
        "${k}=${v}"
    }
    // let's say you have diverse agents among Windows, Mac and Linux all of
    // which have proper labels for their platform and what browsers are
    // available on those agents.
    String nodeLabel = "os:${axis['PLATFORM']} && browser:${axis['BROWSER']}"
    tasks[axisEnv.join(', ')] = { ->
        node(nodeLabel) {
            withEnv(axisEnv) {
                stage("Build") {
                    echo nodeLabel
                    sh 'echo Do Build for ${PLATFORM} - ${BROWSER}'
                }
                stage("Test") {
                    echo nodeLabel
                    sh 'echo Do Build for ${PLATFORM} - ${BROWSER}'
                }
            }
        }
    }
}

stage("Matrix builds") {
    parallel(tasks)
}

マトリクス軸の組み合わせは以下のとおりです。

[PLATFORM=linux, BROWSER=firefox]
[PLATFORM=windows, BROWSER=firefox]
[PLATFORM=mac, BROWSER=firefox]
[PLATFORM=linux, BROWSER=chrome]
[PLATFORM=windows, BROWSER=chrome]
[PLATFORM=mac, BROWSER=chrome]
[PLATFORM=windows, BROWSER=safari]
[PLATFORM=mac, BROWSER=safari]
[PLATFORM=windows, BROWSER=edge]

注目すべき点として、Jenkinsのエージェントラベルにはコロン(:)を使うことができます。ですから、 os:linuxbrowser:firefox はどちらも有効なエージェントラベルです。ノードの式 os:linux && browser:firefoxは、両方のラベルが付いたJenkinsエージェントを検索します。

マトリクスパイプラインの画面ショット

次の図は、上記のパイプラインコードをサンドボックスのJenkins環境で実行しているところです。

静的な選択肢の追加

ビルドが開始されたときにユーザーがマトリクスのビルドをカスタマイズできれば便利です。上記のスクリプトを多少変更するだけで、静的な選択肢を追加できます。静的な選択肢は、次の例のように、質問とマトリクスフィルターをハードコーディングします。

Jenkinsfile
Map response = [:]
stage("Choose combinations") {
    response = input(
        id: 'Platform',
        message: 'Customize your matrix build.',
        parameters: [
            choice(
                choices: ['all', 'linux', 'mac', 'windows'],
                description: 'Choose a single platform or all platforms to run tests.',
                name: 'PLATFORM'),
            choice(
                choices: ['all', 'chrome', 'edge', 'firefox', 'safari'],
                description: 'Choose a single browser or all browsers to run tests.',
                name: 'BROWSER')
        ])
}

// filter the matrix axes since
// Safari is not available on Linux and
// Edge is only available on Windows
List axes = getMatrixAxes(matrix_axes).findAll { axis ->
    (response['PLATFORM'] == 'all' || response['PLATFORM'] == axis['PLATFORM']) &&
    (response['BROWSER'] == 'all' || response['BROWSER'] == axis['BROWSER']) &&
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

すると、パイプラインコードは次の選択肢をダイアログに表示します。

ユーザーがカスタマイズされたオプションを選択すると、パイプラインはリクエストされたオプションに応じた処理を実行します。

動的な選択肢の追加

動的な選択肢とは、パイプライン開発者がハードコードした選択ダイアログではなく、 Map matrix_axesから生成したダイアログによってユーザーがビルドをカスタマイズすることを意味します。

ユーザーエクスペリエンス (UX) を考慮すれば、利用可能なマトリクス軸を自動的に反映した選択肢を表示することが望ましいでしょう。たとえば、マトリクスに Java を表す新しい軸を追加するとします。

// you can add more axes and this will still work
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
    JAVA: ['openjdk8', 'openjdk10', 'openjdk11'],
    BROWSER: ['firefox', 'chrome', 'safari', 'edge']
]

動的な選択肢をサポートするには、次のように選択肢とマトリクス軸フィルターを更新する必要があります。

Map response = [:]
stage("Choose combinations") {
    response = input(
        id: 'Platform',
        message: 'Customize your matrix build.',
        parameters: matrix_axes.collect { key, options ->
            choice(
                choices: ['all'] + options.sort(),
                description: "Choose a single ${key.toLowerCase()} or all to run tests.",
                name: key)
        })
}

// filter the matrix axes since
// Safari is not available on Linux and
// Edge is only available on Windows
List axes = getMatrixAxes(matrix_axes).findAll { axis ->
    response.every { key, choice ->
        choice == 'all' || choice == axis[key]
    } &&
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

すると、利用可能なマトリクス軸に基づいて動的に選択肢が生成され、ユーザーがカスタマイズした内容に応じて自動的に選択肢が表示されます。下の図は、サンプルのダイアログと、パイプライン実行時に表示される選択肢になります。

動的な選択肢を含むパイプライン全体のサンプル

次のスクリプトは、動的な選択肢を含む完全なパイプラインのサンプルです。

// you can add more axes and this will still work
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
    JAVA: ['openjdk8', 'openjdk10', 'openjdk11'],
    BROWSER: ['firefox', 'chrome', 'safari', 'edge']
]

@NonCPS
List getMatrixAxes(Map matrix_axes) {
    List axes = []
    matrix_axes.each { axis, values ->
        List axisList = []
        values.each { value ->
            axisList << [(axis): value]
        }
        axes << axisList
    }
    // calculate cartesian product
    axes.combinations()*.sum()
}

Map response = [:]
stage("Choose combinations") {
    response = input(
        id: 'Platform',
        message: 'Customize your matrix build.',
        parameters: matrix_axes.collect { key, options ->
            choice(
                choices: ['all'] + options.sort(),
                description: "Choose a single ${key.toLowerCase()} or all to run tests.",
                name: key)
        })
}

// filter the matrix axes since
// Safari is not available on Linux and
// Edge is only available on Windows
List axes = getMatrixAxes(matrix_axes).findAll { axis ->
    response.every { key, choice ->
        choice == 'all' || choice == axis[key]
    } &&
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

// parallel task map
Map tasks = [failFast: false]

for(int i = 0; i < axes.size(); i++) {
    // convert the Axis into valid values for withEnv step
    Map axis = axes[i]
    List axisEnv = axis.collect { k, v ->
        "${k}=${v}"
    }
    // let's say you have diverse agents among Windows, Mac and Linux all of
    // which have proper labels for their platform and what browsers are
    // available on those agents.
    String nodeLabel = "os:${axis['PLATFORM']} && browser:${axis['BROWSER']}"
    tasks[axisEnv.join(', ')] = { ->
        node(nodeLabel) {
            withEnv(axisEnv) {
                stage("Build") {
                    echo nodeLabel
                    sh 'echo Do Build for ${PLATFORM} - ${BROWSER}'
                }
                stage("Test") {
                    echo nodeLabel
                    sh 'echo Do Build for ${PLATFORM} - ${BROWSER}'
                }
            }
        }
    }
}

stage("Matrix builds") {
    parallel(tasks)
}

背景情報: 動作の仕組み

秘訣はaxes.combinations()*.sum()にあります。Groovyのcombinationsは、簡単に直積集合を求められる方法です。

次のよりシンプルなサンプルは、直積集合の仕組みを表しています。2つの単純なリストを取り、組み合わせを作成します。

List a = ['a', 'b', 'c']
List b = [1, 2, 3]

[a, b].combinations()

[a, b].combinations()の結果は次のとおりです。

[
    ['a', 1],
    ['b', 1],
    ['c', 1],
    ['a', 2],
    ['b', 2],
    ['c', 2],
    ['a', 3],
    ['b', 3],
    ['c', 3]
]

同じサンプルで、a, b, cおよび1, 2, 3の代わりに、マトリクスマップを使用してみましょう。

List java = [[java: 8], [java: 10]]
List os = [[os: 'linux'], [os: 'freebsd']]

[java, os].combinations()

[java, os].combinations()の結果は次のとおりです。

[
    [ [java:8],  [os:linux]   ],
    [ [java:10], [os:linux]   ],
    [ [java:8],  [os:freebsd] ],
    [ [java:10], [os:freebsd] ]
]

これを単一のマップとして簡単に利用するには、マップを1つに加算して単一のマップを作成する必要があります。たとえば、 [java: 8] + [os: 'linux'] を加算すると、単一のハッシュマップ[java: 8, os: 'linux']になります。つまり、パイプラインで利用しやすくするには、マップのリストのリストを単純なマップのリストにする必要があるということです。

それには、Groovyのスプレッド演算子 (axes.combinations()*.sum()*. )を利用します。

同じ java/osサンプルで、スプレッド演算子を利用した場合を見てみましょう。

List java = [[java: 8], [java: 10]]
List os = [[os: 'linux'], [os: 'freebsd']]

[java, os].combinations()*.sum()

結果は次のとおりです。

[
    [ java: 8,  os: 'linux'],
    [ java: 10, os: 'linux'],
    [ java: 8,  os: 'freebsd'],
    [ java: 10, os: 'freebsd']
]

スプレッド演算子を使用すると、最終的な結果であるマップのリストをマトリクス軸として利用できます。また、Groovy ListfindAll {} メソッドを使用してわかりやすくマトリクスのフィルタリングを行うことができます。

共有ライブラリパイプラインステップを公開する

上記のコードを共有ライブラリパイプラインステップとして公開するのがベストプラクティスです。サンプルとして、Jervisにvars/getMatrixAxes.groovyを追加しました。この柔軟性の高い共有ライブラリステップを自分の共有パイプラインライブラリにコピーできます。

次の例のように、シンプルな1次元のマトリクスでステップを利用するのが簡単になります。

Jenkinsfile
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
]

List axes = getMatrixAxes(matrix_axes)

// alternately with a user prompt
//List axes = getMatrixAxes(matrix_axes, user_prompt: true)

次のサンプルは、フィルタリングを伴う2次元のマトリクスを使用する、より複雑な例です。

Jenkinsfile
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
    BROWSER: ['firefox', 'chrome', 'safari', 'edge']
]

List axes = getMatrixAxes(matrix_axes) { Map axis ->
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

そして次は、フィルタリングとユーザー入力のプロンプトを伴う3次元のマトリクスの例です。

Jenkinsfile
Map matrix_axes = [
    PLATFORM: ['linux', 'windows', 'mac'],
    JAVA: ['openjdk8', 'openjdk10', 'openjdk11'],
    BROWSER: ['firefox', 'chrome', 'safari', 'edge']
]

List axes = getMatrixAxes(matrix_axes, user_prompt: true) { Map axis ->
    !(axis['BROWSER'] == 'safari' && axis['PLATFORM'] == 'linux') &&
    !(axis['BROWSER'] == 'edge' && axis['PLATFORM'] != 'windows')
}

共有ライブラリを使用する場合は、スクリプトの承認は不要です。

共有ステップを提供しない場合、マトリクスビルドをエンドユーザーに公開するには、スクリプト承認設定で次のメソッドを承認する必要があります。

Script approval
staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods combinations java.util.Collection

まとめ

スクリプトパイプラインでマトリクスビルドを実行する方法、またユーザーがマトリクスビルドをカスタマイズできるようプロンプトを表示する方法を説明しました。さらに、ビルド可能なマトリクス軸の取得処理を vars/getMatrixAxes.groovyを通じて簡単に利用できる 共有ライブラリステップとしてユーザーに公開するサンプルを示しました。管理者がユーザーをサポートする際は、Grooveメソッドをホワイトリスト化するのではなく、共有ライブラリステップを利用することが強く推奨されます。

Jervis shared pipeline libraryは、2017年からJenkinsスクリプトパイプラインでのマトリクスビルドをサポートしています(サンプルはこちらこちらを参照)。

著者について

Sam Gleske

Integral Ad ScienceのSenior Software Engineer。全社的なCI/CD導入の拡大のためのJenkinsソリューションを開発しています。そのために、技術やプロジェクトだけでなく、人々をJenkinsに導くことに力を入れるJervis: Jenkins as a serviceの開発を続けています。業務以外では、自発的に時間を費やしてJenkinsプロジェクトなどのオープンソースソフトウェアへの貢献を楽しんでいます。