🎉

Jenkins Job DSL の処理の流れについて

2022/10/23に公開

2022年にもなって今更? という感じもあるが、ふとしたきっかけで調べたので備忘録的にまとめておく。 Jenkins, Groovy のどちらも詳しくなく、実装を斜め読みして把握しただけなので、記述内容の信頼性は低い。

また、ある程度 Groovy 自体の言語機能を把握していないと読みづらい部分があるので、雰囲気でジョブを記述している自分のような人向けに、そのあたりも一緒にまとめておく。

前提知識

Jenkins: ジョブ設定

Jenkins のジョブ設定は、 {{ JENKINS_HOME }}/jobs/{{ JOB_NAME }}/config.xml に配置されている。また、このジョブ設定ファイルは http://{{ JENKINS_WEB_SERVER }}/job/{{ JOB_NAME }}/config.xml のように HTTP エンドポイント経由でも取得できる。

補足: JENKINS_HOME の値

日本語 : Administering Jenkins によるとデフォルトは ~/.jenkins らしいが、 /var/lib/jenkins/jobs/{{ JOB_NAME }}/config.xml になっていることも多いと思う。

Web UI 上で JENKINS_HOME を確認する場合は、システムの設定ページ (http://{{ JENKINS_WEB_SERVER }}/configure のような URL) の「ホームディレクトリ」の項目に記載されているので、そこを確認する。

Jenkins: ビルドステップに対応するクラス

Jenkins のビルドステップは BuildStep インタフェースを実装する形で実現されている。実際には BuildStep インタフェースを継承した Builder クラスを継承することで実装している様子。これは Jenkins にビルトインされているか否か(プラグインか)に関わらず、 Builder クラスを実装している様で、 Builder - Extension Points defined in Jenkins Core には実装されたビルドステップに対応するクラスが一覧になっている。

また、ビルドステップのメインの処理に対応するのは perform メソッドなので、どの様な処理をしているか把握する場合は、このメソッドを確認することになる。

参考資料

Groovy: 演算子オーバーロード

他の言語と同様に演算子のオーバーロードは存在する。少し意識すべき点として、 plusdiv, call など、一見普通のメソッドに見える名前をしている。

参考資料

Groovy: クラスのプロパティ

Groovy では、 setName, getName のように JavaBeans の仕様 に沿ったメソッドを用意することで、 obj.name = "Tanaka" のように利用することが出来る。

参考資料

Groovy: methodMissing メソッドと invokeMethod メソッド

methodMissing は Ruby の method_missing メソッドのようなメソッド。メソッドが見つからなかった際に呼び出されるメソッド。

一方、 invokeMethod が定義されている場合は任意のメソッド呼び出し時に呼び出される。

参考資料

Groovy: クロージャの記法

クロージャは { [closureParameters -> ] statements } のような形で記述できる。

関数の末尾の引数としてクロージャを渡す場合、以下のように丸括弧の外にクロージャを記述できる

f(arg1, arg2) { println "hello" }
補足

公式ドキュメント中にこの構文に関する記述がパッと見つからなかったが、「Programming Groovy 2」の「4.1 The Convenience of Closures」には記載を見つけた。また、 groovy/GroovyParser.g4 at GROOVY_4_0_X · apache/groovy あたりを真面目に読めば、正確な構文も把握出来ると思われる)。

参考資料

Groovy: クロージャの delegate まわりの挙動

クロージャ内部では this, own, delegate という外部のオブジェクトに紐付けられた変数が利用できる。また、クロージャ内でオブジェクトを明示的に指定せずにプロパティにアクセスしようとした場合、 this, own, delegate に紐付けられたオブジェクトのプロパティとして解決しようとする。この際、どの順番で解決しようと試みるかは、クロージャの resolveStrategy プロパティの値によって制御される。

さらに、 delegate はクロージャの delegate プロパティの値を変更することで、クロージャ内部で delegate に紐付けられるオブジェクトを変更することが出来る。

The Apache Groovy programming language - Closures - 3.2.4. Delegation strategy にある例(以下引用)を見ると基本的な挙動が把握しやすい。

class Person {
    String name
}
def p = new Person(name:'Igor')
def cl = { name.toUpperCase() }
cl.delegate = p
assert cl() == 'IGOR'
参考資料

Groovy: Categories と use メソッド

以下のように、 Integer 型には minute というプロパティは存在しないが、 use メソッドとカテゴリクラスを利用することで、そのスコープの中でのみ利用可能なメソッドを追加することが出来る。

use(TimeCategory) {
    println 1.minute.ago
}

カテゴリクラスは、拡張メソッドがすべて static メソッドとして実装されており、実装するメソッドの第1引数の型が拡張したいクラス(上記の例では Integer 型)であるように実装されていれば良い。クラスに @Category アノテーションをつけることで、カテゴリクラスに変換する方法もある。

参考資料

Groovy: NodeBuilder クラス

NodeBuilder は、木構造を内部 DSL で記述できるようにするクラス。 Groovy では他にも DOMBuilderJsonBuilder のような、内部 DSL で各種データ形式が記述できるクラスが存在する。これらは BuilderSupport クラスを継承することで実装されている。

参考資料

Groovy: BuilderSupport クラス

BuilderSupport クラスは invokeMethod メソッドを利用して実装されている。メソッド末尾にクロージャを渡し、さらにそのクロージャの delegate プロパティに BuilderSupport のインスタンスを渡すことで、木構造を内部 DSL で表現できるようにしている。

以下は JsonBuilder を利用するサンプル。 Groovy としては item, id, tags はすべて BuilderSupport のメソッドとして解決される。

JsonBuilder builder = new JsonBuilder()
builder.item {
    id "12345ab"
    tags "a", "b", "c"
}
String json = JsonOutput.prettyPrint(builder.toString())
得られる Json 文字列
{
    "item": {
        "id": "12345ab",
        "tags": [
            "a",
            "b",
            "c"
        ]
    }
}
参考資料

Groovy: Script クラスと GroovyShell クラス

Groovy のスクリプトは Script クラスを継承したクラスにコンパイルされる。また、 GroovyShell クラスを利用することで文字列から Script へコンパイルし、実行する事ができる。

GroovyShellCompilerConfiguration クラスのインスタンスを渡すことで文字列から クラスへコンパイルする際の挙動を変更することができ、 scriptBaseClass プロパティに Script クラスを継承したクラス名を指定することで、そのクラスをベースとしたクラスにコンパイルされるようになる。つまり、スクリプト上でそのクラスのメソッドが、インスタンスを指定せずに利用できるようになる。

以下は The Apache Groovy programming language - Domain-Specific Languages - 3.1. The Script class に記載されているサンプルコードを引用したもの。スクリプト中の setNamegreetMyBaseClass のメンバ変数やメソッドとして解釈される。

abstract class MyBaseClass extends Script {
    String name
    public void greet() { println "Hello, $name!" }
}

def config = new CompilerConfiguration()
config.scriptBaseClass = 'MyBaseClass'
def shell = new GroovyShell(this.class.classLoader, config)
shell.evaluate """
    setName 'Judith'
    greet()
"""
参考資料

Jenkins Job DSL コードリーディング

Job DSL スクリプト上での job(...){...} の評価部分(前半)

Jenkins Job DSL Architecture · jenkinsci/job-dsl-plugin Wiki によると、 DSL Job スクリプトは JobParent クラス のコンテキストで実行されるとのことなので、ここを出発点に読み進めていく。

DSL Job スクリプトでの job(...){...} は Groovy としては JobParent のメソッドとして実装されている。

これらは内部で processItem メソッドを呼び出しており、内部では各種ジョブに対応するクラス(たとえば jobFreeStyleJob クラス)のインスタンスに with メソッド を利用してクロージャの内容を反映(詳細は後述)した上で、 referencedJobs メンバ変数に追加している。

この with メソッドは、自身を引数に渡されたクロージャの delegate に紐付けることで、クロージャ内でインスタンスを明示せずに扱えるようにしている。なので、以下のような DSL Job スクリプトでは、 stepsFreeStyleJob クラスのメソッドとして解決される。

job ("test-job") {
  steps {
    ...
  }
}

以降、簡単のために FreeStyleJob クラスのケースに絞って追っていく。

Job DSL スクリプト実行〜ジョブ設定の更新処理

FreeStyleJob クラス内部の処理をさらに追う前に、 FreeStyleJob クラスのインスタンスがどのように利用され、最終的にジョブ設定の更新が行われるのかを見ていく。

Jenkins Job DSL Architecture · jenkinsci/job-dsl-plugin Wiki によると、 ExecuteDslScripts クラスは Jenkins のプラグインとしての本体で、そこから始まってジョブ設定の更新処理を行うとのことなので、 ExecuteDslScripts クラスの perform メソッドの実装を読んでいく。

読んでいくと、途中で Job DSL スクリプトが ScriptRequest クラスのインスタンスに変換されている事がわかる。また、その ScriptRequestJenkinsDslScriptLoader クラスrunScripts メソッドで実行されていることが分かる。

補足

正確には JenkinsDslScriptLoader クラスだけでなく、 ScriptApprovalDslScriptLoader クラスなどが利用される可能性もあるが、ここでは簡単のため1つに絞る。

runScripts メソッドは JenkinsDslScriptLoader クラスが継承している AbstractDslScriptLoader クラスで実装されている。内部では customizeCompilerConfiguration メソッドが呼び出されており、ここで scriptBaseClassJenkinsJobParent クラスを指定している。その後 runScriptEngine メソッドが呼び出され、 ScriptRequestScript クラスに変換して実行している。

Script クラスに変換&実行後は、 FreeStyleJob クラスなどのインスタンスが JenkinsJobParent クラスの referencedJobs メンバ変数に追加されているので、 extractGeneratedItems メソッドJenkinsJobManagement クラスの createOrUpdateConfig メソッドJenkinsJobManagement クラスの createNewItem メソッド の流れでたらい回しにされた後、 FreeStyleJob クラスが継承している Item クラスの getXml メソッドで XML 文字列に変換後、ジョブ設定の更新が行われている。

Job DSL スクリプト上での job(...){...} の評価部分(後半)

ふたたび FreeStyleJob クラス内部の処理を追っていく。

FreeStyleJob クラスは FreeStyleJob -> Project -> Job -> Item -> AbstractContext -> Context のようなクラスの継承関係があり、例えば steps メソッドは Project クラス に実装されている。

さらに steps メソッド内部での処理を追っていくと、まずはじめに StepContext クラスのインスタンスを作成し、 ContextHelper クラスを利用して StepContext をクロージャの delegate に紐付けた上でクロージャを実行している。なので、以下のような DSL Job スクリプトでは、 shellStepContext クラスのメソッドとして解決される事がわかる。

job ("test-job") {
  steps {
    shell "echo test"
  }
}

shell メソッド の内部を確認すると、 NodeBuilderNode クラスのインスタンスを作成して、それを stepNodes メンバ変数に追加していることが分かる。また、他の StepContext クラスのメソッドも同様に stepNodes に追加している。この stepNodesProject クラスの steps メソッド内で利用されている。

ここで現れる configure メソッドは Item クラスのメソッドで、 configureBlocks メンバ変数へのクロージャの追加だけを行っている。

他の Project クラスのメソッドでも内部で configure メソッドを利用していることから、 DSL Job スクリプト上で steps と同じ階層に記載されている設定値は、 configureBlocks に追加されるクロージャに対応付くことが分かる。

getXml メソッド内部の処理

最後に Item クラスの getXml メソッドでどのように XML 文字列が生成されているかを把握する。

getXml メソッドは getNode メソッド を利用しており、ここで configureBlocks に追加されたクロージャの評価が行われている。 getNode メソッドでは、 ContextHelper クラスの executeConfigureBlocks メソッド を利用して、ジョブ設定の雛形となる Node クラスのインスタンス(FreeStyleJob の場合は FreeStyleJob-template.xml を変換したもの)を各クロージャの処理で変更を加えることで、最終的に欲しいジョブ設定に対応する Node クラスのインスタンスを生成している。

executeConfigureBlocks メソッド内部の処理をもう少し追うと、 executeConfigureBlock メソッドで各クロージャを処理しており、ここで NodeEnhancement クラスをカテゴリクラスとして扱っており、それによってクロージャ内で Node クラスに子ノードを作成する際に /<< を利用できるようになっている。

なので、例えば steps メソッド で追加されるクロージャでは、ジョブ設定に対応する Node クラスのインスタンスに builders という子ノードを追加し、さらにその子ノードとして stepNodes の各要素を追加するという処理を行うことが分かる。

Discussion