ぴよ丸水産

週末ファゴッティストによる技術ブログ

【Ansible】Ansibleロール単体テストツールMoleculeを触ってみた

はじめに

AnsibleロールをテストするツールMoleculeを試してみました。
参考:公式ドキュメント
ついでにJenkins+Gitと連携させて、
インフラCIのパイプラインに乗っかる感じで使ってみました。

前提

  • Jenkinsマスター・Jenkinsスレーブ・GitLabはDockerコンテナ
  • PlaybookのテストはCentos8のDockerコンテナで実行させる
    • JenkinsスレーブにDockerをインストール
    • ホストOSのdocker daemonを共有させているので、Dockerコンテナが立ち上がるのは、ホストOS上(Not Docker in Docker)
  • テストコードもPlaybookで記述
  • PlaybookはGitLabのsample_playbookリポジトリに格納

環境構成

構成図

f:id:blue-38:20200715171357p:plain

バージョン情報

項目
Ansible 2.9.0
Molecule 3.0.5
ansible-lint 4.2.0
Docker 18.09.8-ce
Jenkins 2.176.1
Jenkins Remoting 3.29
Git 2.22.4

シナリオ

  1. Ansibleロールを作成
  2. Ansibleロールを単体テストするPlaybookを作成
  3. GitにPushする
  4. Jenkinsジョブ実行
  5. moleculeテスト結果出力

3→4とか自動化したいとこですが、
そこまではやってません。

Ansibleロール作成

下記のコマンドでmolecule設定ファイルを含んだロールが生成されます。
moecule init role <ロール名>
今回は下記のコマンドを実行しました。
molecule init role sample
sampleロールが生成されます。

※Moleculeを動かしてみることが目的なので、とってもイージーなPlaybookです。
testと記載されたファイルを/tmp/test.txtに配置するだけのPlaybookです。

  • ./roles/sample/tasks/main.yml
---
- name: copy test file
  copy:
    src: test.txt
    dest: /tmp/test.txt
  • ./roles/sample/files/test.txt
test

【参考】既存のロールにmolecule設定ファイルを追加する場合

ロールのディレクトリ配下に移動して、下記のコマンドを実行します。
molecule init scenario -r <ロール名>

ちなみに1階層上(roles直下)実行すると、以下のようなエラーが出ます。
ロールのディレクトリ直下で実行しましょう。
ERROR: The role '<ロール名>' not found. Please choose the proper role name.

Ansibleロールを単体テストするPlaybookを作成

molecule.ymlの編集

単体テストコードを作成する前に、moleculeの動作設定を確認しておきましょう。

  • ./roles/sample/molecule/default/molecule.yml
---
dependency:
  name: galaxy
driver:
  name: docker
lint:
  ansible-lint
platforms:
  - name: instance
    image: docker.io/pycontribs/centos:8
    pre_build_image: true
provisioner:
  name: ansible
verifier:
  name: ansible

今回デフォルトから変更した点は、以下の点です。
ansible-lintによる静的解析を実行したいので下記の2行を追記しました。

lint:
  ansible-lint

lintの定義はコマンドさながらに定義できるので、
例えばansible-lintで無視させたいルールがあるとしたら、
下記のように定義すればOKです。

lint:
  ansible-lint -x 201

※201のルールを無視させています(参考)

デフォルトから変えていませんが、
前提条件で謳っていた下記の項目はmolecule.ymlの定義に依存しています。

  • PlaybookのテストはCentos8のDockerコンテナで実行させる
driver:
  name: docker
platforms:
  - name: instance
    image: docker.io/pycontribs/centos:8
    pre_build_image: true
  • テストコードもPlaybookで記述
verifier:
  name: ansible

ほかにもansibleオプションを定義できたり、
テストをtestinfraというpython製のツールで実行できたり、
いろいろ設定できるようです。
詳細は公式ドキュメントをご確認ください。

テストコードの作成

moleculeコマンドで生成されるverify.ymlにテスト処理を定義します。

  • ./roles/sample/molecule/default/verify.yml
---
- name: Verify
  hosts: all
  tasks:
  - name: Verify with wait_for module
    wait_for:
      path: /tmp/test.txt
      search_regex: "{{ item }}"
      timeout: 5
    with_items:
      - "test"
  - name: Verify with command module Execution
    command: cat /tmp/test.txt
    register: command_result
    ignore_errors: true
    changed_when: false
  - name: Verify with command module Check Result
    assert:
      that:
        - "'test' in command_result.stdout"

テスト対象のPlaybookが、
testと記載されたファイルを/tmp/test.txtに配置するだけのPlaybook
なので、
配置したtest.txtにtestという文字列が含まれているか
を確認するPlaybookを作成しました。

下記の2種類の確認方法(やっていることはほぼ同じ)を仕込んでみました。

  • wait_forモジュールを使用した確認
    • Verify with wait_for module
  • commandモジュールを使用した確認
    • Verify with command module Execution
    • Verify with command module Check Result

※冪等性の試験とかもいつか試したいと思います。

GitにPushする

作成した./roles/sampleをGitリポジトリsample_playbookにpushします。

Jenkinsジョブ実行

Jenkinsには以下の設定がしてある前提です。

  • 認証情報にgit-credentialを登録している
  • Jenkinsスレーブにslave-nodeというラベルを付与している

Jenkinsの新規ジョブ作成から下記の設定のジョブを作成します。

  • ジョブ名:Execute_Molecule
  • 種類:パイプライン

ビルドのパラメータ化にチェックを入れ、下記のパラメータを設定します。

パラメータ名 デフォルト値
GIT_REPOSITORY_PATH 文字列 http://127.0.0.1/gitlab/sample_playbook.git
ROLE_PATH テキスト sample
pipeline{
    agent{
        node{
            label 'slave-node'
        }
    }
    stages{
        stage('Gitクローン'){
            steps{
                deleteDir()
                git(
                    url: GIT_REPOSITORY_PATH,
                    credentialsId: 'git_credential',
                    branch: 'master'
                    )
            }
        }
        stage('molecule実行'){
            steps{
                script{
                    def roles = ROLE_LIST.split("\n")
                    sh "mkdir logs"
                    roles.each{ role ->
                        echo """Start Testing ${role}"""
                        dir("${WORKSPACE}/roles/${role}"){
                            def result = sh (
                                script: "/usr/local/bin/molecule test",
                                returnStdout: true
                            )
                            echo "${result}"
                            writeFile(
                                file: "${WORkSPACE}/logs/${role}_molecule.log",
                                text: "${result}"
                            )
                        }
                    }
                }
            }
        }
        stage('Archive artifacts'){
            steps{
                archiveArtifacts "logs/*"
            }
        }
    }
}

上記のジョブを作成し、
下記のパラメータを設定して実行してみてください。

パラメータ
GIT_REPOSITORY_PATH Gitのリポジトリパス
ROLE_LIST Moleculeを実行したいロール名(改行区切りで複数指定可)

moleculeテスト結果出力

ジョブに定義したパイプラインを見れば気づくと思いますが、
moleculeの実行結果を<ロール名>_molecule.logに出力し、
Jenkinsジョブの成果物として保存されます。

こんなログが出てくるかと思います。

--> Test matrix
└── default
    ├── dependency
    ├── lint
    ├── cleanup
    ├── destroy
    ├── syntax
    ├── create
    ├── prepare
    ├── converge
    ├── idempotence
    ├── side_effect
    ├── verify
    ├── cleanup
    └── destroy
--> Scenario: 'default'
--> Action: 'dependency'
Skipping, missing the requirements file.
Skipping, missing the requirements file.
--> Scenario: 'default'
--> Action: 'lint'
--> Executing: ansible-lint
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'
--> Sanity checks: 'docker'
    PLAY [Destroy] *****************************************************************
    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=instance)
    TASK [Wait for instance(s) deletion to complete] *******************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    TASK [Delete docker network(s)] ************************************************
    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
--> Scenario: 'default'
--> Action: 'syntax'
    playbook: /home/jenkins/agent/workspace/Execute_Molecule/ansible_playbooks/sample/molecule/default/converge.yml
--> Scenario: 'default'
--> Action: 'create'
    PLAY [Create] ******************************************************************
    TASK [Log into a Docker registry] **********************************************
    skipping: [localhost] => (item=None)
    TASK [Check presence of custom Dockerfiles] ************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    TASK [Create Dockerfiles from image names] *************************************
    skipping: [localhost] => (item=None)
    TASK [Discover local Docker images] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    TASK [Build an Ansible compatible image (new)] *********************************
    skipping: [localhost] => (item=molecule_local/docker.io/pycontribs/centos:8)
    TASK [Create docker network(s)] ************************************************
    TASK [Determine the CMD directives] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    TASK [Create molecule instance(s)] *********************************************
    changed: [localhost] => (item=instance)
    TASK [Wait for instance(s) creation to complete] *******************************
    FAILED - RETRYING: Wait for instance(s) creation to complete (300 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost]
    PLAY RECAP *********************************************************************
    localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=4    rescued=0    ignored=0
--> Scenario: 'default'
--> Action: 'prepare'
Skipping, prepare playbook not configured.
--> Scenario: 'default'
--> Action: 'converge'
    PLAY [Converge] ****************************************************************
    TASK [Gathering Facts] *********************************************************
    ok: [instance]
    TASK [Include sample] **********************************************************
    TASK [sample : copy test file] ****************************************************
    changed: [instance]
    PLAY RECAP *********************************************************************
    instance                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
--> Scenario: 'default'
--> Action: 'idempotence'
Idempotence completed successfully.
--> Scenario: 'default'
--> Action: 'side_effect'
Skipping, side effect playbook not configured.
--> Scenario: 'default'
--> Action: 'verify'
--> Running Ansible Verifier
    PLAY [Verify] ******************************************************************
    TASK [Gathering Facts] *********************************************************
    ok: [instance]
    TASK [Verify with wait_for module] *********************************************
    ok: [instance] => (item=test)
    TASK [Verify with command module Execution] ************************************
    ok: [instance]
    TASK [Verify with command module Check Result] *********************************
    ok: [instance] => {
        "changed": false,
        "msg": "All assertions passed"
    }
    PLAY RECAP *********************************************************************
    instance                   : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
Verifier completed successfully.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'
    PLAY [Destroy] *****************************************************************
    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=instance)
    TASK [Wait for instance(s) deletion to complete] *******************************
    changed: [localhost] => (item=None)
    changed: [localhost]
    TASK [Delete docker network(s)] ************************************************
    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
--> Pruning extra files from scenario ephemeral directory

上記、クリアしている例ですが、
何かしらテストがNGになると、
molecule testコマンドのリターンコードが0以外になるので、
ジョブが異常終了します。

おわりに

PlaybookもCIしていくって考え、大事だとおもいます。
業務で触っているということもあり、Jenkinsに乗せてみましたが、
Github actionやGitLab runnerを使用したインフラCIもいつか試したいと思います。

参考

molecule.readthedocs.io

qiita.com

qiita.com

techblog.ap-com.co.jp

www.amazon.co.jp