Pre-commitとAnsible-Lintを使ってcommit前にコード規約をテストする

本記事は、[Ansible Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ansible_01) の24日目です。

# はじめに

ここではpre-commitとAnsible-lintを使って、コードをcommitする前に静的コード解析を行うための手法をご紹介します。
Ansibleを開発するにあたって、Ansible-lintや、yamllintを導入したことがある方は多いと思いますが、以下のような不満を目にしたことがあります。

* 開発メンバーにコードをpushする前にlintをかけてとお願いしたが徹底できない
* CI/CDを回してからSyntaxエラーが見つかる

他にも挙げればきりがないですが、よく聞く話ですとここら辺でしょうか。
特にCI/CDを回してからSyntaxエラーが見つかるって、やるせないですし、動かないコードが含まれたcommitがpushされること自体がストレスですよね。

そこで紹介したいのがpre-commitです。

# pre-commit

まずはpre-commitのネームスペース衝突問題から解説しましょう。
「**pre-commit**」というキーワードで検索されると以下の二つが検索に引っかかります。

* **gitの機能であるpre-commit hooksの略称**
* **「pre-commit」と言うOSSソフトウェア**

今回紹介するのは後者で、pre-commitと言うOSSソフトウェアのことを取り扱います。
このドキュメンテーションでは前者を**git pre-commit hooks**、後者のことを**pre-commit**と表記することにします。

では次に、pre-commitとは何かをお話しさせてください。

pre-commitとは**gitのpre-commit hooksの為の多言語パッケージマネージャー**のことです、**gitのpre-commit hooks**を便利に管理してくれるフレームワークだと思ってください。

# pre-commitの特徴
pre-commitが叶えてくれる事は以下のような事です。

* git pre-commit hooksのスクリプト管理
* プロジェクト事に異なるgit pre-commit hooksのconfiguration化
* commit前にコードの問題を静的解析で見つけてくれる

特に筆者が優れているなと思えるのは下記の部分になります。

* .pre-commit-config.yamlに記載するだけで、外部のレポジトリからlintにの実行に必要なライブラリをインストールしてくれる
* git pre-commit hooksに対する知識がなくても簡単に導入できる
* localのコマンドも設定でき、自作のlint toolも動かすことができる

では早速使い方を紹介していきます。

# pre-commitのインストール

pre-commitのインストールはとても簡単で、以下の手順だけで済みます。

```
curl https://pre-commit.com/install-local.py | python3 -
```

# pre-commitのconfiguration

pre-commitのconfigrationはgit repositoryのrootに**.pre-commit-config.yaml**と言う名前のファイルを作成して設定していきます。
また、pre-commit自体がsample-configを吐き出してくれるオプションも持っています。

```
❯ pre-commit sample-config
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v3.2.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files
```

簡単にconfigurationを説明すると、「repos」の中にある「repo」が提供されるlintの一塊りだと思ってください。
その中にある「id」が単一のlint ruleのようなものです。

上記のconfigartionの中にある「id」がどう言った振る舞いをしているかに関しては、以下のドキュメンテーションが理解の助けになります。
[Support hooks](https://pre-commit.com/hooks.html)

ここで一つ注意が必要なのが、**-fixer**の名前で終わっているhooksに関しては**修正**まで行ってしまうことに気をつけてください。

一つ一つのconfigurationについては、pre-commitのドキュメンテーションをみてもらえればいいと思っています。
[Creating new hooks](https://pre-commit.com/#new-hooks)

# pre-commitの使い方
Step by Stepの手順ではありませんが、みなさんpre-commitのconfigurationまで作れたでしょうか。
それでは実際にpre-commitを使っていこうと思います。

pre-commitは設定ファイルを書いただけでは動作してくれません、それはなぜでしょう？
それは、git pre-commit hooksを利用して動くので、.git/hooks/pre-commitにpre-commit用のpythonスクリプトを配置しなければならないからです。

それでは早速、スクリプトの配置を行っていきましょう。
**.pre-commit-config.yaml**が配置されているカレントディレクトリで以下のコマンドを実施してください。

```
❯ pre-commit install
pre-commit installed at .git/hooks/pre-commit
```

**pre-commit installed at .git/hooks/pre-commit**と言うメッセージが出てくれば成功です。

もちろん、このスプリプトの中身も見ることができます。
どうやらpre-commitで必要なライブラリはHome directoryの.pre-commit-venvに格納するみたいですね。
virtual envを切って実装してくれるのはとてもありがたいですね。

```
❯ cat .git/hooks/pre-commit
#!/usr/bin/env python3
# File generated by pre-commit: https://pre-commit.com
# ID: 138fd403232d2ddd5efb44317e38bf03
import os
import sys

# we try our best, but the shebang of this script is difficult to determine:
# - macos doesn't ship with python3
# - windows executables are almost always `python.exe`
# therefore we continue to support python2 for this small script
if sys.version_info < (3, 3):
    from distutils.spawn import find_executable as which
else:
    from shutil import which

# work around https://github.com/Homebrew/homebrew-core/issues/30445
os.environ.pop('__PYVENV_LAUNCHER__', None)

# start templated
INSTALL_PYTHON = '/Users/yyamashi/.pre-commit-venv/bin/python3'
ARGS = ['hook-impl', '--config=.pre-commit-config.yaml', '--hook-type=pre-commit']
# end templated
ARGS.extend(('--hook-dir', os.path.realpath(os.path.dirname(__file__))))
ARGS.append('--')
ARGS.extend(sys.argv[1:])

DNE = '`pre-commit` not found.  Did you forget to activate your virtualenv?'
if os.access(INSTALL_PYTHON, os.X_OK):
    CMD = [INSTALL_PYTHON, '-mpre_commit']
elif which('pre-commit'):
    CMD = ['pre-commit']
else:
    raise SystemExit(DNE)

CMD.extend(ARGS)
if sys.platform == 'win32':  # https://bugs.python.org/issue19124
    import subprocess

    if sys.version_info < (3, 7):  # https://bugs.python.org/issue25942
        raise SystemExit(subprocess.Popen(CMD).wait())
    else:
        raise SystemExit(subprocess.call(CMD))
else:
    os.execvp(CMD[0], CMD)
```

それではpre-commitを実際に走らせてみましょう。

まずは、git pre-commit hooksを経由しないで直接pre-commitを実行してみましょう。
その場合は**pre-commit runコマンド**を利用します。
ただこの時注意なのが基本的にはgit addされてstagingに上がっているファイルのみをlintにかける振る舞いをします、なのでgit addしてlintにかけたいファイルをgit addしてから試してください。

```
❯ git add .pre-commit-config.yaml
❯ git add .
❯ pre-commit run
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
```

問題なく通りましたね、では次にあえてエラーを発生させてみましょう。
以下のようにnginxを呼び出す為のsite.ymlの一部をkey value pareに変更してみました。
この記法はAnsibleでは問題なく通りますが、yamlのsyntaxでは認められていませんので、check-yaml hooksエラーを検知できることを期待しています。

```
❯ git diff
diff --git a/site.yml b/site.yml
index c839566..3221f74 100644
--- a/site.yml
+++ b/site.yml
@@ -3,7 +3,7 @@
   name: 'Install ando configure nginx'
   gather_facts: true
   tasks:
-    - name: 'Import nginx role'
+    - name='Import nginx role'
       import_role:
         name: 'nginx'

❯ git add site.yml
❯ pre-commit run
Fix End of Files.........................................................Passed
Check Yaml...............................................................Failed
- hook id: check-yaml
- exit code: 1

mapping values are not allowed in this context
  in "site.yml", line 7, column 18

Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
```
確かにLine7でエラーを検知できていますね!

では、最後にgit commitを実行してpre-commitがgit pre-commit hooksから呼び出されるか確認しましょう。

```
❯ git commit -m '[Add] Add nginx and haproxy roles'
Fix End of Files.........................................................Passed
Check Yaml...............................................................Failed
- hook id: check-yaml
- exit code: 1

mapping values are not allowed in this context
  in "site.yml", line 7, column 18

Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
```
実験成功ですね！これで、pre-commitの導入自体は完了です。


# pre-commitとansible-lintのintegration
さて、ここからが本筋です、pre-commitにansible-lintをインテグレーションして、実践的で今すぐ使えるpre-commit環境を作っていきましょう。

まずは以下のようなconfigurationを作りましょう。

```
❯ cat .pre-commit-config.yaml
---
# yamllint disable rule:quoted-strings
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v3.2.0
    hooks:
      - id: end-of-file-fixer
      - id: check-yaml
      - id: mixed-line-ending
      - id: check-case-conflict
      - id: check-merge-conflict
  - repo: https://github.com/ansible/ansible-lint.git
    rev: v4.3.7
    hooks:
      - id: ansible-lint
        name: Ansible-lint
        description: This hook runs ansible-lint.
        entry: ansible-lint
        language: python
        # do not pass files to ansible-lint, see:
        # https://github.com/ansible/ansible-lint/issues/611
        always_run: true
        pass_filenames: false
  - repo: https://github.com/adrienverge/yamllint.git
    rev: v1.25.0
    hooks:
      - id: yamllint
        files: \.(yaml|yml)$
        types: [file, yaml]
        entry: yamllint -c .yamllint.yml --strict
```

次にgit add .pre-commit-config.yamlを実行して、実際にpre-commitからansible-lintが呼び出せるか確認しましょう。

```
❯ git add .pre-commit-config.yaml
❯ pre-commit run
Fix End of Files.........................................................Passed
Check Yaml...............................................................Failed
- hook id: check-yaml
- exit code: 1

mapping values are not allowed in this context
  in "site.yml", line 7, column 18

Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
Ansible-lint.............................................................Passed
yamllint.................................................................Failed
- hook id: yamllint
- exit code: 1

site.yml
  7:18      error    syntax error: mapping values are not allowed here (syntax)
```

ansible-lintとyamllintの処理が追加されていますね。
先ほどあえて引っかかるように作った、site.ymlがansible-lintでは通るが、yamllintとcheck-yamlで引っかかっていることが確認できるかと思います。

site.ymlを修正して引っかかる箇所がないかを確認します。

```
❯ git diff
diff --git a/site.yml b/site.yml
index 3221f74..c839566 100644
--- a/site.yml
+++ b/site.yml
@@ -3,7 +3,7 @@
   name: 'Install ando configure nginx'
   gather_facts: true
   tasks:
-    - name='Import nginx role'
+    - name: 'Import nginx role'
       import_role:
         name: 'nginx'

❯ git add site.yml
❯ pre-commit run
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
Ansible-lint.............................................................Passed
yamllint.................................................................Passed
```
問題なく通りましたね、では次にansible-lintで引っかかるような部分を作って、動作を確認してみましょう。

```
❯ git diff
diff --git a/roles/nginx/molecule/docker/create.yml b/roles/nginx/molecule/docker/create.yml
--- a/roles/nginx/molecule/docker/create.yml
+++ b/roles/nginx/molecule/docker/create.yml
@@ -10,14 +10,14 @@
       register: 'result'

     - name: 'Build container image'
-      command: 'docker build -t centos8_ssh .'
+      shell: 'docker build -t centos8_ssh .'
       args:
         chdir: './assets'
       changed_when: false
       when: '"localhost/centos8_ssh" not in result.stdout'

❯ git add roles/nginx/molecule/docker/create.yml

❯ git add .pre-commit-config.yaml
❯ git commit -m '[Add] Add molecule for docker'
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
Ansible-lint.............................................................Failed
- hook id: ansible-lint
- exit code: 2

[305] Use shell only when shell functionality is required
roles/nginx/molecule/docker/create.yml:12
Task/Handler: Build container image

You can skip specific rules or tags by adding them to your configuration file:

┌──────────────────────────────────────────────────────────────────────────────┐
│ # .ansible-lint                                                              │
│ warn_list:  # or 'skip_list' to silence them completely                      │
│   - '305'  # Use shell only when shell functionality is required             │
└──────────────────────────────────────────────────────────────────────────────┘

yamllint.................................................................Passed
```

きちんとshellじゃなくて、commandを使え！って怒られましたね。
実験成功です！

それでは問題の箇所を修正してcommitをしてみましょう！

```
❯ vim -c 12 roles/nginx/molecule/docker/create.yml
❯ git diff
diff --git a/roles/nginx/molecule/docker/create.yml b/roles/nginx/molecule/docker/create.yml
--- a/roles/nginx/molecule/docker/create.yml
+++ b/roles/nginx/molecule/docker/create.yml
@@ -10,14 +10,14 @@
       register: 'result'

     - name: 'Build container image'
+      command: 'docker build -t centos8_ssh .'
-      shell: 'docker build -t centos8_ssh .'
       args:
         chdir: './assets'
       changed_when: false
       when: '"localhost/centos8_ssh" not in result.stdout'

❯ git add roles/nginx/molecule/docker/create.yml
❯ git commit -m '[Add] Add molecule for docker'
[INFO] Stashing unstaged files to /Users/yyamashi/.cache/pre-commit/patch1608791209.
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Mixed line ending........................................................Passed
Check for case conflicts.................................................Passed
Check for merge conflicts................................................Passed
Ansible-lint.............................................................Passed
yamllint.................................................................Passed
[INFO] Restored changes from /Users/yyamashi/.cache/pre-commit/patch1608791209.
[master 197b9da] [Add] Add molecule for docker
 1 file changed, 1 insertion(+), 1 deletion(-)
```

やったー！コミットすることに成功しました！

細かいyammlintや、ansible-lintのconfigは**.yamllint.yml**や、**.ansible-lint**に記述してあげてください。

私たちのチームではコーディング規約をlint rule化しているので、以下のようなconfigを書いています。

```
❯ cat .ansible-lint
exclude_paths:
  - .pre-commit.yaml ### コーディング規約で.yamlを許可していないので、泣く泣くexclude...
parseable: false
quiet: false
rulesdir:
  - ./rules/         ### Custom lint ruleのディレクトリ
skip_list:
  - '301'
use_default_rules: true
```

# 終わりに

これでみなさんのansible開発レポジトリのcommitも綺麗になること間違いなしですね！


お付き合いいただき、ありがとうございました。
引き続きAnsibleアドベントカレンダーをおたのしみください！

明日はHideki Saitoさんが今年のAnsibleを締め括ってくれます！