Jenkinsのジョブをまじめにいい感じに管理してみた（Ansible編）

# はじめに

最近、CIや分散スケジュールとして色々なタスクを動かす事が多くなってきました。
タスク実行にはJenkinsを採用し、シェルやPythonスクリプトを流しています。
そんな時、大量にあるJenkinsのjobの管理に頭を抱えてきました。

特にデータ分析あたりの細かいタスクは
細かいタスクが無数に発生します。
それをGUIで設定して回るのには疲弊しました。

データのバックアップ時間が間に合わなかったので数多くのタスクをGUIで設定してまわったり、
sedでjobファイルを直接編集したり・・・。

なんとかいい感じに管理したいなーって思って考えた結果
全部AnsibleでJenkinsのjobを管理しデプロイまで行うことにしました。

## なにがしたかったの？

以下のような目的で、JenkinsのjobをAnsibleで管理し始めました

* 柔軟に時間指定をしてスクリプトを実行できること
* CUIで完結すること
* Github等のプルリクエストでシェルのコードレビューが出来ること
* 同じような処理に少しだけ違うパラメータを加える事が出来ること
* 冪等性が保証されること
* GUIからでも何をやってるかわかること
* 運用が楽なこと

上記目標が達成出来るなら方法はなんでも良かったです。
むしろJenkinsからは脱却する方向で考えていました。
同じぐらいの結果だったら今までの運用ノウハウがあるのでAnsibleにしたかなぁ。
jobのXMLを配るスクリプトとか書きましたが
イマイチいい感じに上記目的を満たせなかった為ボツになりました。

## 管理方法

Ansibleのjenkins_job_moduleを使いました。
http://docs.ansible.com/ansible/latest/jenkins_job_module.html

ただ、このモジュールちょっと？癖があります。
> config in XML format.

**ジョブを作成する引数が一つのXML文字列**なのです。
多分想定用途としては出来上がったJenkinsのjobをコピーしておいて
違うサーバにコピーするとか、そういう目的だろうなーという気はするのですが。

このままではいい感じに管理できません。
CUIで完結するぐらいしかメリットがないのです。
なので、そこはAnsibleに頑張ってもらう事としました。
これから記載されるのは、Ansibleのtemplate機能を使い
いかにいい感じにXML文字列を作るか、です。

### テンプレートを用意する
通常のジョブを使うのか、pipelineを使うかによって少し変わるのですが
Jenkinsのjobは基本同じ形のXMLで定義されています。
なので、新規jobを作りそこからXML文字列を持ってきます。
初期セットアップでJenkinsを構築した場合以下のパスに格納されているはずです。
>/var/lib/jenkins/jobs/${job_name}/config.xml。

中身は多分こんな感じじゃないかな

```xml:config.xml
<?xml version='1.0' encoding='UTF-8'?>
<project>
  <description></description>
  <keepDependencies>false</keepDependencies>
  <properties/>
  <scm class="hudson.scm.NullSCM"/>
  <canRoam>true</canRoam>
  <disabled>false</disabled>
  <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
  <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
  <triggers/>
  <concurrentBuild>false</concurrentBuild>
  <builders/>
  <publishers/>
  <buildWrappers/>
</project>
```

ここから、少しづつテンプレート化していきます。

### ジョブをリスト変数で定義する

Ansibleにはユーザ定義で変数を定義する方法がいくつかあります。 
[詳しくはこのあたり](http://docs.ansible.com/ansible/latest/playbooks_variables.html)
どの方法でもいいので、作りたいジョブを定義しましょう。
Ansibleのrole内のvarsファイルにする場合はこんな感じかな？

```yml:vars/main.yml
jenkins:
  list_job:
    canonic:
      - name: example_job_a_1
      - name: example_job_a_2
```

taskのディレクトリ構成はこんな感じになっているはず。

```:directory
$ tree
.
├── tasks
│   └── main.yml (現時点では空ファイル）
├── templates
│   └── config.xml（jenkinsのコンフィグ用テンプレートファイル）
└── vars
    └── main.yml（ジョブの定義がかかれた変数ファイル）

```

### ジョブをデプロイしてみる

taskの中身は以下のようになるでしょう。

```yml:tasks/main.yml
- jenkins_job:
    name: "{{ item.name }}"
    config: "{{ lookup('template', 'config.xml') }}" 
    state: present
  with_items: "{{ jenkins.list_job.canonic }}"
```
もしライブラリがないって怒られたら
Ansibleのドキュメントにあるように以下のモジュールを入れておきましょう。

* python-jenkins >= 0.4.12
* lxml >= 3.3.3

また、Jenkinsに認証がある場合、その認証方法を引数に加えてください。(ID/PasswordとかTokenとか）

さて、ここまで出来ていればAnsibleが通るはずです。
そうすると…？

![jenkins_ansible.png](https://qiita-image-store.s3.amazonaws.com/0/146781/5064b61e-662a-8649-c5fb-e78dd8e4cd59.png)
念願の、JenkinsをGUI操作で作成する手間から卒業です！
Jenkinsおじさんは卒業です！
Jenkinsおじさん(CLI)へ進化です！
（テンションたかめ）

しかし、これだけでは全然いい感じに管理出来ていません。
上の目標にあげていたCLI完結ぐらいしか出来ていません。

### スクリプトをtemplateで埋め込む

今回一番記事にしたかったのはこれです。
やりすぎると黒魔術になってしまうので注意が必要ですが、
**Ansibleのtempateモジュールからはtemplateを呼べます。**
templateの過度な使用は可読性の点から避けているのですが
JenkinsのXMLなんて元々可読性低いし良いか、という気持ちでtemplateを使用しています。

configのtemplateに以下のような記載をします。

```diff:templates/config.xml
-  <builders/>
+  <builders>
+  {% if jenkins.default.build.command %}
+  {% autoescape true %}
+    <hudson.tasks.Shell>
+      <command>{{ lookup('file', default.build.command.script_path) }}</command>      
+    </hudson.tasks.Shell>
+  {% endautoescape %}
+  {% endif -%}
+  </builders>
```
そして、変数の定義部分に以下の記載を追加します。

```yml:vars/main.yml
  default:
    build:
      command:
        script_path: example.sh
```
この構造は好みや要件が違うので、スクリプトを配置している場所を参照できればなんでもいいと思います。
ジョブごとに違うシェルを実行する場合は以下のようになるでしょう。

```diff:templates/config.yml
-  <builders>
+  <builders>
+  {% if item.script_path %}
+  {% autoescape true %}
+    <hudson.tasks.Shell>
+      <command>{{ lookup('file', item.script_path) }}</command>      
+    </hudson.tasks.Shell>
+  {% endautoescape %}
+  {% endif -%}
+  <builders/>
```
変数

```yml:vars/main.yml
jenkins:
  list_job:
    canonic:
      - name: example_job_1
        script_path: example_1.sh
      - name: example_job_2
        script_path: example_2.py
```
そしてスクリプトを参照できる箇所に配置します。

重要なのは、autoescapeを埋め込みましょう。
このスクリプトはtemplateで埋め込まれてXMLの中に入るのですが
autoescapeしないとダブルクォートなど一部記号などが含まれるjobのデプロイに失敗します。
autoescapeは、使用する際ansible.cfgに記載する必要があるので、追記しておきましょう。

```cfg:ansible.cfg
jinja2_extensions = jinja2.ext.autoescape
```

はい、そうしてデプロイし直すとジョブが上書きされます。
そのジョブの設定を確認すると・・・？
![jenkins_ansible_2.png](https://qiita-image-store.s3.amazonaws.com/0/146781/8fb9fda3-d65a-aa7b-4d3e-4593d5e28e55.png)

無事にシェルがJenkinsのjob上で展開されてます！
いい感じですよね！
ちなみにXMLの中身はこのようになっています。

```xml+jinja:/var/lib/jenkins/jobs/example_task/config.xml

  <builders>
        <hudson.tasks.Shell>
      <command>#!/bin/bash

echo "Hello Jenkins CLI"

for i in {1..10}
do
 echo $i
done
echo "日本語出力"</command>
    </hudson.tasks.Shell>
    </builders>
```

このぐらいの内容だとまだ人間でも読めるのですが
エスケープされてくる内容が増えると生XML管理は厳しくなってきます。

```xml:example_task_b/config.xml
  <builders>
        <hudson.tasks.Shell>
      <command>#!/bin/bash

cp &quot;test_dir/work&quot; ./
ls -la
str_date=`date &apos;+%Y%m%d&apos;`
sed -i -e &quot;s/@date@/${str_date}/g&quot; example.txt

echo &quot;&quot;
    </hudson.tasks.Shell>
    </builders>
```
このように。
これがシェルスクリプトだからいいのですが、
Pythonのスペースとかを気にすると少しつらいものが出てきます。

Ansibleで管理し、Githubなどの下に置くと
実行するスクリプトだけ分離されるので管理が楽になるんじゃないかなと思います。
処理内容が変わった時にスクリプトだけプルリクエストにかけたり。

スクリプトだけ別管理にして、Jenkinsのjobからスクリプトを叩く事もできるのですが
その場合GUIのJenkinsジョブを見ただけではどういう処理が走っているかわかりづらいので
Ansibleでデプロイするのはいい感じな落としどころじゃないかなぁと思います。

### jobごとにパラメータを付与する

しかし、これでは違う処理になるたびにちょっとづつ違うスクリプトを用意しなければいけません。
取得テーブルがすこし違うだけのスクリプトを大量に管理するのであれば
スクリプトを分離した意味があまりありません。

なので、jobに用意されている「パラメータ」を使います。
jobを一つの関数として考え、引数を別に用意するような感じですね。

まず、上記テンプレートの以下の部分をけずり

```diff:template/config.xml
-  <properties/>
```

以下のように、引数を展開して追加できるようにします。

```xml+jinja:templates/config.xml
  <properties>
    <hudson.model.ParametersDefinitionProperty>
      <parameterDefinitions>
	
	{% if item.param %}
	{% for datum in item.param %}
        <hudson.model.{{ datum.type }}ParameterDefinition>
          <name>{{ datum.name }}</name>
          <description>{{ datum.description }}</description>
          <defaultValue>{{ datum.default }}</defaultValue>
        </hudson.model.{{ datum.type }}ParameterDefinition>
	{% endfor %}
	{% endif -%}
	
      </parameterDefinitions>
    </hudson.model.ParametersDefinitionProperty>
  </properties>
```

Ansibleの変数定義には以下のようにパラメータの記載をします。

```yml:vars/main.yml
jenkins:
  default:
    param:
      - name: example_text
        type: Text
        description: this param is example text.
        default: "{{ lookup('file', 'test_config.ini') }}"
      - name: example_string
        type: String
        description: this param is example string.
        default: "test_property"
```

xml上のnameには引数名、descriptionには説明（これは任意ですね）、
typeには引数の型を入力します。
型は複数行のTextか単一行のStringぐらいしか使っていないですが
Jenkins上で使われる型は全て定義出来るでしょう。
そして、defaultには値を入力します。

Jenkinsは定期実行や何も指定せずに実行した場合はdefault値がそのまま使われるので
default=実行時に出力して欲しい値、ぐらいの認識で使っています。
そしてこの値をlookupなどでファイルから取り出せるので
長めの文字列を別管理したいなどの用途でも使えます。

スクリプト上では、以下のように環境変数としてパラメータは取り出せます。

```bash:files/example_script.sh
    echo -e "${example_text}"
```
bashでなくPythonの場合はos.environやos.getenvを使うと良いでしょう。
この二手間でタスクを処理とメッセージに分離できるので
だいぶん管理しやすくなるんじゃないかなと思います。

### 必要な所を変数化

あとは、jobにもたせてあげたい設定をどんどん変数化しておけばいいでしょう。
ジョブスケジューラとして使う場合はtriggersタグを変数化する必要があると思いますし
タスク分散の場合はassignedNodeタグを修正すればいいでしょう。
このあたりはjobの要件などにそって設定すればいいかなと思います。

### まとめ

最終的にtemplateの内容はこんな感じになりました。

```xml+jinja:templates/config.xml
<?xml version='1.0' encoding='UTF-8'?>
<project>
  <actions/>
  <description>{{ item.description }}</description>
  <keepDependencies>true</keepDependencies>
  <properties>
     {% if jenkins.default.log_rotation_day %}
    <jenkins.model.BuildDiscarderProperty>
      <strategy class="hudson.tasks.LogRotator">
	<daysToKeep>{{ jenkins.default.log_rotation_day }}</daysToKeep>
	<numToKeep>-1</numToKeep>
	<artifactDaysToKeep>-1</artifactDaysToKeep>
	<artifactNumToKeep>-1</artifactNumToKeep>
      </strategy>
    </jenkins.model.BuildDiscarderProperty>
    {% endif -%}

    <hudson.model.ParametersDefinitionProperty>
      <parameterDefinitions>
	
	{% if jenkins.default.param %}
	{% for datum in jenkins.default.param %}
        <hudson.model.{{ datum.type }}ParameterDefinition>
          <name>{{ datum.name }}</name>
          <description>{{ datum.description }}</description>
          <defaultValue>{{ datum.default }}</defaultValue>
        </hudson.model.{{ datum.type }}ParameterDefinition>
	{% endfor %}
	{% endif -%}

	{% if item.param %}
	{% for datum in item.param %}
        <hudson.model.{{ datum.type }}ParameterDefinition>
          <name>{{ datum.name }}</name>
          <description>{{ datum.description }}</description>
          <defaultValue>{{ datum.default }}</defaultValue>
        </hudson.model.{{ datum.type }}ParameterDefinition>
	{% endfor %}
	{% endif -%}
	
      </parameterDefinitions>
    </hudson.model.ParametersDefinitionProperty>
  </properties>
  <scm class="hudson.scm.NullSCM"/>
  {% if jenkins.default.slave_node %}
  <assignedNode>{{ jenkins.default.slave_node }}</assignedNode>
  <canRoam>false</canRoam>
  {% else %}
  <canRoam>true</canRoam>
  {% endif -%}
  <disabled>false</disabled>
  <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
  <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
  <triggers>
  {% if item.timer_trigger %}
     <hudson.triggers.TimerTrigger>
       <spec>{{ item.timer_trigger }}</spec>
     </hudson.triggers.TimerTrigger>
  {% endif %}
  </triggers>
  <concurrentBuild>false</concurrentBuild>
  <builders>
  {% if jenkins.default.build.command %}
  {% for datum in jenkins.default.build.command %}
  {% autoescape true %}
    <hudson.tasks.Shell>
      <command>{{ lookup('file', datum.script_path) }}</command>      
    </hudson.tasks.Shell>
  {% endautoescape %}
  {% endfor %}
  {% endif -%}
  </builders>
  <publishers>
  {% if jenkins.default.publish %}
  {% for datum in jenkins.default.publish %}
  {{ lookup('file', datum.xml_path) }}
  {% endfor %}
  {% endif -%}
  </publishers>
  <buildWrappers/>
</project>
```

このXMLだけ見るとうっとなるのですが、
実際に作られたジョブやドキュメントを見ながらだと案外単純に出来るんじゃないかなーって思います。

このXMLに対応する変数はこんな感じになりました。

```yml:vars/main.yml
jenkins:
  list_job_a_group:
    - name: 
      list_job:
        - name: a_one_job
          timer_trigger: H 20 * * *
        - name: a_two_job
          timer_trigger: H 20 * * *
      param:
        - name: param_foo
          type: String
          description: foo variable
          default: foo
        - name: param_bar
          type: String
          description: bar variable
          default: bar
  list_job_b_group:
    - name: 
      list_job:
        - name: b_one_job
          timer_trigger: H 18 * * *
        - name: b_two_job
          timer_trigger: H 19 * * *
      param:
        - name: param_sample
          type: Text
          description: sample variable
          default: example_long_string
    slave_node: 
    log_rotation_day: 14
    build:
      command:
        - name: b_task
          script_path: example_task.py
    publish:
      - name: failed_build
        xml_path: failed_build.xml.part
```

一回作ってしまえば使い回しも効いて幸せになるなーって感想です。
特にちょっとしたシェルスクリプトを修正してレビューして
多くのジョブに適用させる時とかはだいぶん安心感があります。
しかもAnsibleの範囲内で冪等性があるので、変更したjobにだけ変更がかかる安心が！

### おしまい

数多くのJenkinsのjobをGUIから手で設定して疲れていたり
Jenkinsのjob上で動くスクリプトの処理に困っているタスク管理者の皆さん。
AnsibleでJenkinsのjob管理をしてみるのはいかがでしょうか？