Testinfra+GitLab-CIで、「テストされたAnsibleロール」を作ってみる

※少し前に、実戦投入目標で検証してたやつの振り返り用です。

サーバーのミドルウェア構成なんかをAnsibleで管理する機会がだいぶ増えてきました。
で、趣味ベースで「Galaxyには登録しないけど、ある程度内容の正しさを担保してあるロール」というのを作ってみたくなり、こんな組み合わせを構築してみました。

* Ansibleのロールのプロジェクトを作る
* pushするたびに、GitLab-CIする
    * 一緒に起動したCentOSコンテナにAnsibleロールの反映
    * その場で、testinfraを使って、CentOSコンテナ上のテスト

## 登場人物整理

* Ansible
    * おなじみ構成管理ツール。
* GitLab.com, GitLab-CI
    * おなじみOSSなGitHubライクサービス。GitLab-CIが何の手続きもなく使えるのがGood
* testinfra
    * Ansibleと並ぶ今回の主役。インフラ系のテストをpy.testの仕組み上で出来るようにしたもの

# お題

CentOS7のサーバーに対して、PHP7.1(mod_php)を入れるロール
オプションで以下の様なことができるようになること。

* remi-phpXXのバージョンを指定したら、そのバージョンのPHPをインストールできるようにする
* 選択式で、php-fpmをインストールできるようにする
* 追加のphp拡張もインストールできるようにする

## 流れ

1. `ansible-galaxy`でロールのソース一式を新規作成
1. ansible反映先となるコンテナ用のDockerイメージを準備
1. `.gitlab-ci.yml`を用意して、`ansible-playbook`+`testinfra`が通るようにする
1. testinfraのテストコードを書きつつ、ロールを実装 

# 作ってみる

## 1. `ansible-galaxy`でロールのソース一式を新規作成

`ansible-galaxy`コマンドには`init`というサブコマンドがあり、`defaults`, `handlers`, `meta`, `tasks`, `tests`, `vars`といったロールに必要なフォルダとファイルの一式を用意してくれます。`--offline`オプションを使えば、AnsibleGalaxyも見にいかないのでお手軽です。
今回は、.travis.ymlの存在は忘れてください。

### testsフォルダ

デフォルトで作成される`tests`フォルダ内には、シンタクスチェックのみを目的としたインベントリとplaybookファイルがあります。

## 2. ansible反映先となるコンテナ用のDockerイメージを準備

GitLab-CIでは`services`内にDockerイメージを記載することで、CI実行中にテスト実行用のコンテナとは別にコンテナを動かすことが出来ます。
今回はお題の条件をみたすために、「CentOS 7 + SSHサーバー」なイメージを用意します。


```dockerfile:attakei/ansible-target:centos-7-latest
FROM jdeathe/centos-ssh:centos-7

# 「rootでログインはしない」「`yum install`は使う」のために設定
ADD sudoers /etc/sudoers.d/app-admin
```

## 3. `.gitlab-ci.yml`を用意して、`ansible-playbook`+`testinfra`が通るようにする

```yaml:.gitlab-ci.yml
---
image: williamyeh/ansible:alpine3
stages:
  - lint
  - test

services:
  - attakei/ansible-target:centos-7-latest

before_script:
  - cd tests
  - cp hosts/insecure_private_key ~/
  - chmod 400 ~/insecure_private_key

syntax_check:
  stage: lint
  script:
    - ansible-playbook test.yml --syntax-check
```

ここまでがtestinfraの準備段階のgitlab-ci.ymlです。
AnsibleのシンタックスチェックとGitLab-CIの仕組みを利用して、文法ミスを見つけたらNGを出すようにしておくと効率が上がると思います。

## 4. testinfraのテストコードを書きつつ、ロールを実装 

ここからテスト用にPlaybook+テストコードを書きつつ、ロールを実装していきます。

```yaml:.gitlab-ci.yml
# (一部略)

before_script:
  - pip install -q testinfra

# (抜粋)
test_install_default:
  stage: test
  script:
    - ansible-playbook test_install_default.yml
    - py.test --ssh-config=hosts/ssh_config --hosts=server test_install_default.py
```

```yaml:test_install_default.yml
---
- hosts: targets
  roles:
    - role: .
```

```py3:test_install_default.py
# -*- coding:utf8 -*-


def test_has_dependencies(host):
    assert host.package("epel-release").is_installed
    assert host.package("remi-release").is_installed


def test_installed_php(host):
    assert host.package('php').is_installed
    php_version = host.check_output("php -v")
    assert 'PHP 7.1' in php_version
```

`.gitlab-ci.yml`上にテスト用のジョブを用意していきます。
その際に、ジョブ名とジョブ実行時に使うPlaybookとそれを検証するテストファイルを同じ名前にしておくとわかりやすいです。

### ここでテストを実行する

GitLab-CIを使う場合だと、gitlab-runnerを用意することでローカルでも動作確認が使えるのが大きいです。

```bash:terminail
❯ gitlab-runner exec docker test_install_default
Running with gitlab-ci-multi-runner 9.0.2 (fa8b86d3)
  on  ()
(略)
$ chmod 400 ~/insecure_private_key

$ ansible-playbook test_install_default.yml

PLAY [targets] *****************************************************************

TASK [Gathering Facts] *********************************************************
ok: [server]

PLAY RECAP *********************************************************************
server                     : ok=1    changed=0    unreachable=0    failed=0

$ py.test --ssh-config=hosts/ssh_config --hosts=server test_install_default.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.13, pytest-3.3.1, py-1.5.2, pluggy-0.6.0
rootdir: /builds/project-0/tests, inifile:
plugins: testinfra-1.10.1
collected 2 items

test_install_default.py FF                                               [100%]

=================================== FAILURES ===================================
___________________ test_has_dependencies[paramiko://server] ___________________

host = <testinfra.host.Host object at 0x7f75605f8550>

    def test_has_dependencies(host):
        assert host.package("epel-release").is_installed
>       assert host.package("remi-release").is_installed
E       AssertionError: assert False
E        +  where False = <package remi-release>.is_installed
E        +    where <package remi-release> = <class 'testinfra.modules.base.RpmPackage'>('remi-release')
E        +      where <class 'testinfra.modules.base.RpmPackage'> = <testinfra.host.Host object at 0x7f75605f8550>.package

test_install_default.py:6: AssertionError
------------------------------ Captured log call -------------------------------
(略)
=========================== 2 failed in 0.86 seconds ===========================
Running after script...
ERROR: Job failed: exit code 1
FATAL: exit code 1

```

当然ながらテストに失敗します。

### タスクを実装

```yaml+jinja:tasks/main.yml
- name: Install EPEL(dependencies)
  become: yes
  yum: name=epel-release
- name: repo's package
  become: yes
  get_url:
    url: https://rpms.remirepo.net/enterprise/remi-release-7.rpm
    dest: /root/remi-release.rpm
- name: Install REMI repo
  become: yes
  yum: name=/root/remi-release.rpm
- name: Install php and basic packages
  become: yes
  yum: name={{ item }} enablerepo=remi,remi-php71
  with_items:
    - php
```

tasks/main.ymlにタスクを追記して、再度実行しましょう。

```bash:terminal
❯ !!
gitlab-runner exec docker test_install_default
Running with gitlab-ci-multi-runner 9.0.2 (fa8b86d3)
(略)
$ ansible-playbook test_install_default.yml

PLAY [targets] *****************************************************************

TASK [Gathering Facts] *********************************************************
ok: [server]

TASK [. : Install EPEL(dependencies)] ******************************************
ok: [server]

TASK [. : repo's package] ******************************************************
changed: [server]

TASK [. : Install REMI repo] ***************************************************
changed: [server]

TASK [. : Install php and basic packages] **************************************
changed: [server] => (item=[u'php'])

PLAY RECAP *********************************************************************
server                     : ok=5    changed=3    unreachable=0    failed=0

$ py.test --ssh-config=hosts/ssh_config --hosts=server test_install_default.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.13, pytest-3.3.1, py-1.5.2, pluggy-0.6.0
rootdir: /builds/project-0/tests, inifile:
plugins: testinfra-1.10.1
collected 2 items

test_install_default.py ..                                               [100%]

=========================== 2 passed in 1.00 seconds ===========================
Running after script...
Job succeeded
```

無事にテストが通りました。

### 実装を進める

テストケースとテスト用Playbookを増やしながら実装をしていきましょう。


[出来上がったのがこちら](https://gitlab.com/attakei/ansible-php-role/)

pipelineもすべてがグリーンです。

<img width="460" alt="gitlab-ci-pipeline.png" src="https://qiita-image-store.s3.amazonaws.com/0/6805/f9aab73e-2c62-7c09-9356-15d8e535a938.png">


# いい点

* GitLab-CIが勝手にテストしてくれるので、「テストコードが正しくテストしてくれる」ことを前提に第三者に「これは正しく動く」ということを客観的に示すことができる
* Ansibleロールとテストコードが同梱できるので、他の人も検証がしやすい

# 難点

* アプリケーションレベルのテストコードにあるようなsetUp/tearDownが使いづらいので、各テストごとにコンテナを起動する必要がある
* Shared Runnerしか使わない場合はリソースの取り合いになるので、更にテスト完了が遅くなりがち
* デーモン起動系のテストが動かしづらい

# まとめ

「このロールは少なくともこの部分を保証してくれる」というのがわかるのは、それだけでも大きいかなと。
そして「テストされたロール」を軸にPlaybookを組み立てることで、少ないリソースでも「ある程度品質が保証されたPlaybook」を作ることも可能かなと思いました。

# 参考

* [.gitlab-ci.ymlリファレンス](https://docs.gitlab.com/ee/ci/yaml/README.html)
* [Testinfra](https://github.com/philpep/testinfra)
* [Python製のサーバ環境テストツールTestinfraの環境構築から動作テストまでの手順](https://qiita.com/yusukew62/items/8cbb54485e67e036cc6a)