Ansible Moleculeを使ってみる

# はじめに
Ansible Moleculeを使ってみました。手元で動かすだけの内容を記載しているだけのページです。

なお、公式ドキュメントとして下記のGetting Startedが用意されています。
https://ansible.readthedocs.io/projects/molecule/working/getting_started/getting_started/

上記手順に完全に沿っているわけではありませんが、Ansible Moleculeを手元で再現するためにドキュメントが少なく色々と試行錯誤しましたので、その備忘録として残しておきます。

# 前提
ここでは参考までに「ansible-galaxy install nginxinc.nginx」でインストールされたコレクションのディレクトリレイアウトを参考として作ってみることにします。
自分は初心者でありディレクトリのベストプラクティスを知らないので先人の例を参考にしています。
```
$ tree ~/.ansible/roles/nginxinc.nginx
~/.ansible/roles/nginxinc.nginx
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SUPPORT.md
├── defaults
│   └── main
│       ├── amplify.yml
│       ├── bsd.yml
│       ├── logrotate.yml
│       ├── main.yml
│       ├── selinux.yml
│       └── systemd.yml
├── files
│   ├── license
│   └── services
│       ├── nginx.conf.upstart
│       ├── nginx.openrc
│       ├── nginx.override.conf
│       ├── nginx.systemd
│       ├── nginx.sysvinit
│       └── nginx.upstart
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── molecule
│   ├── common
│   │   └── Dockerfile.j2
│   ├── default
│   │   ├── converge.yml
│   │   ├── molecule.yml
│   │   └── verify.yml

(snip)

│   ├── upgrade-plus
│   │   ├── converge.yml
│   │   ├── molecule.yml
│   │   ├── prepare.yml
│   │   └── verify.yml
│   └── version
│       ├── converge.yml
│       ├── molecule.yml
│       └── verify.yml
├── tasks
│   ├── amplify
│   │   ├── install-amplify.yml
│   │   ├── setup-debian.yml
│   │   └── setup-redhat.yml
│   ├── config
│   │   ├── debug-output.yml
│   │   ├── modify-systemd.yml
│   │   └── setup-logrotate.yml
│   ├── keys
│   │   └── setup-keys.yml
│   ├── main.yml
│   ├── modules
│   │   └── install-modules.yml

(snip)

│   ├── prerequisites
│   │   ├── install-dependencies.yml
│   │   ├── prerequisites.yml
│   │   └── setup-selinux.yml
│   └── validate
│       └── validate.yml
├── templates
│   ├── logrotate
│   │   └── nginx.j2
│   ├── selinux
│   │   └── nginx-plus-module.te.j2
│   └── services
│       └── nginx.service.override.conf.j2
└── vars
    └── main.yml

35 directories, 96 files
```

# 検証手順を実施した環境
参考までに今回の手順を検証した環境情報を載せておきます。
```
$ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 22.04.1 LTS
Release:	22.04
Codename:	jammy
$ ansible --version
ansible [core 2.15.2]
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/tsuyoshi/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/tsuyoshi/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.10.6 (main, May 29 2023, 11:10:38) [GCC 11.3.0] (/usr/bin/python3)
  jinja version = 3.1.2
  libyaml = True
$ molecule --version
molecule 6.0.2 using python 3.10 
    ansible:2.15.2
    azure:23.5.0 from molecule_plugins
    containers:23.5.0 from molecule_plugins requiring collections: ansible.posix>=1.3.0 community.docker>=1.9.1 containers.podman>=1.8.1
    default:6.0.2 from molecule
    docker:23.5.0 from molecule_plugins requiring collections: community.docker>=3.0.2 ansible.posix>=1.4.0
    ec2:23.5.0 from molecule_plugins
    gce:23.5.0 from molecule_plugins requiring collections: google.cloud>=1.0.2 community.crypto>=1.8.0
    podman:23.5.0 from molecule_plugins requiring collections: containers.podman>=1.7.0 ansible.posix>=1.3.0
    vagrant:23.5.0 from molecule_plugins
```

# 手順 (とりあえず手元で動くまで)
まずはansible collectionの雛形を生成します。
azarashi.utilsというのはnamespace名とcollection名を結合した文字列です。
```
$ ansible-galaxy collection init azarashi.utils
- Collection azarashi.utils was created successfully
$ tree .
.
└── azarashi
    └── utils
        ├── README.md
        ├── docs
        ├── galaxy.yml
        ├── meta
        │   └── runtime.yml
        ├── plugins
        │   └── README.md
        └── roles

6 directories, 4 files
```

続いて、roleの雛形を生成します。
```
$ cd azarashi/utils/roles/
$ ansible-galaxy role init azarashi.utils
- Role azarashi.utils was created successfully
$ tree .
.
└── azarashi.utils
    ├── README.md
    ├── defaults
    │   └── main.yml
    ├── files
    ├── handlers
    │   └── main.yml
    ├── meta
    │   └── main.yml
    ├── tasks
    │   └── main.yml
    ├── templates
    ├── tests
    │   ├── inventory
    │   └── test.yml
    └── vars
        └── main.yml

9 directories, 8 files
```

azarashi.utilsに移動してから、moleculeの雛形をシナリオとして「helloworld」を指定します。
パッケージのインストールのroleを検証するシナリオならば「install」、「uninstall」、「update」などのシナリオ名称になると思われます。
```
$ cd azarashi.utils/
$ molecule init scenario helloworld
INFO     Initializing new scenario helloworld...

PLAY [Create a new molecule scenario] ******************************************

TASK [Check if destination folder exists] **************************************
changed: [localhost]

TASK [Check if destination folder is empty] ************************************
ok: [localhost]

TASK [Fail if destination folder is not empty] *********************************
skipping: [localhost]

TASK [Expand templates] ********************************************************
changed: [localhost] => (item=molecule/helloworld/create.yml)
changed: [localhost] => (item=molecule/helloworld/converge.yml)
changed: [localhost] => (item=molecule/helloworld/molecule.yml)
changed: [localhost] => (item=molecule/helloworld/destroy.yml)

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

INFO     Initialized scenario in /home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils/molecule/helloworld successfully.
```

上記コマンドを実行したことによりmoleculeディレクトリが生成され、その下にシナリオ名であるhelloworldやmoleculeに必要となるymlファイルが生成されています。
```
$ tree .
.
├── README.md
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── molecule
│   └── helloworld
│       ├── converge.yml
│       ├── create.yml
│       ├── destroy.yml
│       └── molecule.yml
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

10 directories, 12 files
```

以上でとりあえずは実行の準備が整いました。

「molecule test」では、デフォルトでカレントディレクトリからの相対パスである「molecule/default/molecule.yml」を探索します。
探索パスを「molecule/helloworld/molecule.yml」とするために--scenario-nameオプションを付与してhelloworldシナリオを指定します。

```
$ pwd
/home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils
$ molecule test --scenario-name helloworld
WARNING  The scenario config file ('/home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils/molecule/helloworld/molecule.yml') has been modified since the scenario was created. If recent changes are important, reset the scenario with 'molecule destroy' to clean up created items or 'molecule reset' to clear current configuration.
INFO     helloworld scenario test matrix: dependency, cleanup, destroy, syntax, create, prepare, converge, idempotence, side_effect, verify, cleanup, destroy
INFO     Performing prerun with role_name_check=0...
INFO     Running from /home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils : ansible-galaxy collection install -vvv --force ../..
INFO     Running helloworld > dependency
WARNING  Skipping, missing the requirements file.
WARNING  Skipping, missing the requirements file.
INFO     Running helloworld > cleanup
WARNING  Skipping, cleanup playbook not configured.
INFO     Running helloworld > destroy

PLAY [Destroy] *****************************************************************

TASK [Populate instance config] ************************************************
ok: [localhost]

TASK [Dump instance config] ****************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

INFO     Running helloworld > syntax

playbook: /home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils/molecule/helloworld/converge.yml
INFO     Running helloworld > create

PLAY [Create] ******************************************************************

TASK [Populate instance config dict] *******************************************
skipping: [localhost]

TASK [Convert instance config dict to a list] **********************************
skipping: [localhost]

TASK [Dump instance config] ****************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

INFO     Running helloworld > prepare
WARNING  Skipping, prepare playbook not configured.
INFO     Running helloworld > converge

PLAY [Converge] ****************************************************************

TASK [Replace this task with one that validates your content] ******************
ok: [instance] => {
    "msg": "This is the effective test"
}
ok: [molecule-ubuntu] => {
    "msg": "This is the effective test"
}

PLAY RECAP *********************************************************************
instance                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
molecule-ubuntu            : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Running helloworld > idempotence

PLAY [Converge] ****************************************************************

TASK [Replace this task with one that validates your content] ******************
ok: [instance] => {
    "msg": "This is the effective test"
}
ok: [molecule-ubuntu] => {
    "msg": "This is the effective test"
}

PLAY RECAP *********************************************************************
instance                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
molecule-ubuntu            : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Idempotence completed successfully.
INFO     Running helloworld > side_effect
WARNING  Skipping, side effect playbook not configured.
INFO     Running helloworld > verify
INFO     Running Ansible Verifier
WARNING  Skipping, verify action has no playbook.
INFO     Verifier completed successfully.
INFO     Running helloworld > cleanup
WARNING  Skipping, cleanup playbook not configured.
INFO     Running helloworld > destroy

PLAY [Destroy] *****************************************************************

TASK [Populate instance config] ************************************************
ok: [localhost]

TASK [Dump instance config] ****************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

INFO     Pruning extra files from scenario ephemeral directory
```

とりあえずはmolecule testを実行できました。
ただし、実行するホスト自体はlocalhostとなっていて、ホストの環境を汚してしまう可能性があります。

そこで、次以降ではdockerイメージでテストを実行するようにしてみます。


# 手順 (dockerイメージでテストを実行させるまで)
dockerイメージでテストを実行させるにはmolecule配下のymlファイルを整備します。

下記の公式ドキュメントを一部参考にしています(※1)
- https://ansible.readthedocs.io/projects/molecule/docker/

意図的にconverge.ymlから呼び出すために大元のtaskに下記のtaskを追加しておきます。
```
$ cat tasks/main.yml 
---
- name: Task is running from within the role
  ansible.builtin.debug:
    msg: "This is a task from my_role."
```

converge.ymlを下記内容で準備します。(※1)のドキュメントから上記のタスクを呼び出すように追記しています。
```
$ cat molecule/helloworld/converge.yml 
- name: Fail if molecule group is missing
  hosts: localhost
  tasks:
    - name: Print some info
      ansible.builtin.debug:
        msg: "{{ groups }}"

    - name: Assert group existence
      ansible.builtin.assert:
        that: "'molecule' in groups"
        fail_msg: |
          molecule group was not found inside inventory groups: {{ groups }}

- name: Converge
  hosts: molecule
  # We disable gather facts because it would fail due to our container not
  # having python installed. This will not prevent use from running 'raw'
  # commands. Most molecule users are expected to use containers that already
  # have python installed in order to avoid notable delays installing it.
  gather_facts: false
  tasks:
    - name: Check uname
      ansible.builtin.raw: uname -a
      register: result
      changed_when: false

    - name: Print some info
      ansible.builtin.assert:
        that: result.stdout | regex_search("^Linux")

    - name: Azarashi's Testing role
      ansible.builtin.include_role:
        name: azarashi.utils
        tasks_from: main.yml
```

続いて、create.yml, destroy.yml, molecule.yml, requirements.ymlを用意します。内容は(※1)と同じです。
```
$ cat molecule/helloworld/create.yml 
- name: Create
  hosts: localhost
  gather_facts: false
  vars:
    molecule_inventory:
      all:
        hosts: {}
        molecule: {}
  tasks:
    - name: Create a container
      community.docker.docker_container:
        name: "{{ item.name }}"
        image: "{{ item.image }}"
        state: started
        command: sleep 1d
        log_driver: json-file
      register: result
      loop: "{{ molecule_yml.platforms }}"

    - name: Print some info
      ansible.builtin.debug:
        msg: "{{ result.results }}"

    - name: Fail if container is not running
      when: >
        item.container.State.ExitCode != 0 or
        not item.container.State.Running
      ansible.builtin.include_tasks:
        file: tasks/create-fail.yml
      loop: "{{ result.results }}"
      loop_control:
        label: "{{ item.container.Name }}"

    - name: Add container to molecule_inventory
      vars:
        inventory_partial_yaml: |
          all:
            children:
              molecule:
                hosts:
                  "{{ item.name }}":
                    ansible_connection: community.docker.docker
      ansible.builtin.set_fact:
        molecule_inventory: >
          {{ molecule_inventory | combine(inventory_partial_yaml | from_yaml) }}
      loop: "{{ molecule_yml.platforms }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Dump molecule_inventory
      ansible.builtin.copy:
        content: |
          {{ molecule_inventory | to_yaml }}
        dest: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        mode: 0600

    - name: Force inventory refresh
      ansible.builtin.meta: refresh_inventory

    - name: Fail if molecule group is missing
      ansible.builtin.assert:
        that: "'molecule' in groups"
        fail_msg: |
          molecule group was not found inside inventory groups: {{ groups }}
      run_once: true # noqa: run-once[task]

# we want to avoid errors like "Failed to create temporary directory"
- name: Validate that inventory was refreshed
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Check uname
      ansible.builtin.raw: uname -a
      register: result
      changed_when: false

    - name: Display uname info
      ansible.builtin.debug:
        msg: "{{ result.stdout }}"
```

```
$ cat molecule/helloworld/destroy.yml 
- name: Destroy molecule containers
  hosts: molecule
  gather_facts: false
  tasks:
    - name: Stop and remove container
      delegate_to: localhost
      community.docker.docker_container:
        name: "{{ inventory_hostname }}"
        state: absent
        auto_remove: true

- name: Remove dynamic molecule inventory
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Remove dynamic inventory file
      ansible.builtin.file:
        path: "{{ molecule_ephemeral_directory }}/inventory/molecule_inventory.yml"
        state: absent
```

```
$ cat molecule/helloworld/molecule.yml 
dependency:
  name: galaxy
  options:
    requirements-file: requirements.yml
platforms:
  - name: molecule-ubuntu
    image: ubuntu:18.04
```

```
$ cat molecule/helloworld/reqirements.yml 
collections:
  - community.docker
```

以上で準備が整いましたので実行しますが、事前にdockerイメージが存在しないことを見ておきます。
```
$ sudo docker ps -a
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
$ molecule list
INFO     Running helloworld > list
                  ╷             ╷                  ╷               ╷         ╷            
  Instance Name   │ Driver Name │ Provisioner Name │ Scenario Name │ Created │ Converged  
╶─────────────────┼─────────────┼──────────────────┼───────────────┼─────────┼───────────╴
  molecule-ubuntu │ default     │ ansible          │ helloworld    │ false   │ false      
                  ╵             ╵                  ╵               ╵         ╵         
```

では、moleculeのtestを下記コマンドにより実行します。
destroy=neverを付与しないと生成したdockerが削除されてしまいましたので意図的に付与しています。
```
$ molecule test --scenario-name helloworld --destroy=never
INFO     helloworld scenario test matrix: dependency, cleanup, destroy, syntax, create, prepare, converge, idempotence, side_effect, verify, cleanup, destroy
INFO     Performing prerun with role_name_check=0...
INFO     Running from /home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils : ansible-galaxy collection install -vvv --force ../..
INFO     Running helloworld > dependency
WARNING  Skipping, missing the requirements file.
WARNING  Skipping, missing the requirements file.
INFO     Running helloworld > cleanup
WARNING  Skipping, cleanup playbook not configured.
INFO     Running helloworld > destroy
WARNING  Skipping, '--destroy=never' requested.
INFO     Running helloworld > syntax
[WARNING]: Could not match supplied host pattern, ignoring: molecule

playbook: /home/tsuyoshi/test/azarashi/utils/roles/azarashi.utils/molecule/helloworld/converge.yml
INFO     Running helloworld > create

PLAY [Create] ******************************************************************

TASK [Create a container] ******************************************************
changed: [localhost] => (item={'image': 'ubuntu:18.04', 'name': 'molecule-ubuntu'})

TASK [Print some info] *********************************************************
ok: [localhost] => {
    "msg": [
        {
            "ansible_loop_var": "item",

(snip)

TASK [Fail if container is not running] ****************************************
skipping: [localhost] => (item=/molecule-ubuntu) 
skipping: [localhost]

TASK [Add container to molecule_inventory] *************************************
ok: [localhost] => (item=molecule-ubuntu)

TASK [Dump molecule_inventory] *************************************************
changed: [localhost]

TASK [Force inventory refresh] *************************************************

TASK [Fail if molecule group is missing] ***************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY [Validate that inventory was refreshed] ***********************************

TASK [Check uname] *************************************************************
ok: [molecule-ubuntu]

TASK [Display uname info] ******************************************************
ok: [molecule-ubuntu] => {
    "msg": "Linux af07e92dba0a 5.15.0-43-generic #46-Ubuntu SMP Tue Jul 12 10:30:17 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux\n"
}

PLAY RECAP *********************************************************************
localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
molecule-ubuntu            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Running helloworld > prepare
WARNING  Skipping, prepare playbook not configured.
INFO     Running helloworld > converge

PLAY [Fail if molecule group is missing] ***************************************

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

TASK [Print some info] *********************************************************
ok: [localhost] => {
    "msg": {
        "all": [
            "molecule-ubuntu"
        ],
        "molecule": [
            "molecule-ubuntu"
        ],
        "ungrouped": []
    }
}

TASK [Assert group existence] **************************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY [Converge] ****************************************************************

TASK [Check uname] *************************************************************
ok: [molecule-ubuntu]

TASK [Print some info] *********************************************************
ok: [molecule-ubuntu] => {
    "changed": false,
    "msg": "All assertions passed"
}

TASK [Azarashi's Testing role] *************************************************

TASK [azarashi.utils : Task is running from within the role] *******************
ok: [molecule-ubuntu] => {
    "msg": "This is a task from my_role."
}

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
molecule-ubuntu            : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Running helloworld > idempotence

PLAY [Fail if molecule group is missing] ***************************************

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

TASK [Print some info] *********************************************************
ok: [localhost] => {
    "msg": {
        "all": [
            "molecule-ubuntu"
        ],
        "molecule": [
            "molecule-ubuntu"
        ],
        "ungrouped": []
    }
}

TASK [Assert group existence] **************************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY [Converge] ****************************************************************

TASK [Check uname] *************************************************************
ok: [molecule-ubuntu]

TASK [Print some info] *********************************************************
ok: [molecule-ubuntu] => {
    "changed": false,
    "msg": "All assertions passed"
}

TASK [Azarashi's Testing role] *************************************************

TASK [azarashi.utils : Task is running from within the role] *******************
ok: [molecule-ubuntu] => {
    "msg": "This is a task from my_role."
}

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
molecule-ubuntu            : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Idempotence completed successfully.
INFO     Running helloworld > side_effect
WARNING  Skipping, side effect playbook not configured.
INFO     Running helloworld > verify
INFO     Running Ansible Verifier
WARNING  Skipping, verify action has no playbook.
INFO     Verifier completed successfully.
INFO     Running helloworld > cleanup
WARNING  Skipping, cleanup playbook not configured.
INFO     Running helloworld > destroy
WARNING  Skipping, '--destroy=never' requested.
```

実行されて意図的にtasks/main.ymlに記載したタスクもconvergeの際に呼び出されていることが確認できました。
Playは冪等性の確認も含めて2度実行されます。

最後に、molecule testの実行によって、dockerイメージが生成されたことを確認しておきます。
```
$ sudo docker ps -a
CONTAINER ID   IMAGE          COMMAND      CREATED              STATUS              PORTS     NAMES
af07e92dba0a   ubuntu:18.04   "sleep 1d"   About a minute ago   Up About a minute             molecule-ubuntu
$ molecule list
INFO     Running helloworld > list
                  ╷             ╷                  ╷               ╷         ╷            
  Instance Name   │ Driver Name │ Provisioner Name │ Scenario Name │ Created │ Converged  
╶─────────────────┼─────────────┼──────────────────┼───────────────┼─────────┼───────────╴
  molecule-ubuntu │ default     │ ansible          │ helloworld    │ true    │ true       
                  ╵             ╵                  ╵               ╵         ╵         
```

# まとめ
ansible moleculeを手元のlocalhostやdockerイメージ上で動かせる様になるまでの手順を記載しました。