Ansible Role のfiles/ 「上書き」を許す実装

# Contents
[1. はじめに](#1-はじめに)
[2. デフォルト動作](#2-デフォルト動作)
[3. 実装](#3-実装)
[4. 終わりに](#4-終わりに)

## 1. はじめに
Ansible Role 内で`ansible.builtin.copy` や`ansible.builtin.template` モジュールを使う場合， Role 内の`files/` or `templates/` 配下のファイルが優先して使われます。

Role 内のディレクトリで見つからなかった場合は， 「親のRole」， 「実行task のディレクトリ内」, 「実行play のディレクトリ内」の順に探索します。（したがって， Play の階層の深いところから順に探索するようになっています）

Role 内で`ansible.builtin.copy` や`ansible.builtin.template` モジュールを使用する場合， Role 内のディレクトリにファイルを配置するのが一般的と考えられるため， この挙動は自然と言えます。

（ドキュメントのリンクは[こちら](https://docs.ansible.com/ansible/latest/playbook_guide/playbook_pathing.html)）

本記事はこれに反し， Ansible Role 内の`files/` or `templates/` 以下に配置したファイルを維持しつつ, Roleを呼び出したPlaybook 側（すなわち, 親側）の`files/` or `templates` 以下に該当ファイルがあればそれを使用したい, といった場合を想定しています。

ただし， 

***Role 実行時のファイル探索の挙動自体を変更するものではなく, Role の実装により対処しています***

したがって, 本記事は次のようなわりと特殊な状況向けです。

* 自身がRole の作成者/管理者 である
* Role がデフォルトで使用するファイル・テンプレートは， Role 内に配置しておきたい
* しかし， Role の実行対象によってはファイルを差し替える必要がある。もしくは， 将来的な差し替えを想定しなければならない
* 当該ファイルをRole 利用者が（任意に）置き換えても， Role の実行自体に影響はなく， セキュリティ上の懸念もない

※ もちろん, 2番目の要求がなければ, 単にRole 内の`files/` or `templates/` ディレクトリ内にファイルを置かなければ良いでしょう。


サンプルコードは[こちら](https://github.com/jkasama/sample-override_role_files)で公開しています。

## 2. デフォルト動作
まずは， デフォルトの動作を確認しましょう。簡単なPlaybook と Role を二つ用意します。
片方のRole では， Role 内に`files/` ディレクトリと`templates` ディレクトリが含まれます。

一方のRole では， 処理は全く同じですが， `files/` ディレクトリ と`templates` ディレクトリを含まない状態としています。

ディレクトリ構成は以下の通りです。
~~~
├── ansible.cfg
├── files
│   └── test_file
├── inventory
├── playbook.yaml
├── roles
│   ├── myns.myrole.test_role
│   │   ├── README.md
│   │   ├── defaults
│   │   │   └── main.yml
│   │   ├── files
│   │   │   └── test_file
│   │   ├── meta
│   │   │   └── main.yml
│   │   ├── tasks
│   │   │   └── main.yml
│   │   └── templates
│   │       └── test_template.j2
│   └── myns.myrole.test_role_2
│       ├── README.md
│       ├── defaults
│       │   └── main.yml
│       ├── meta
│       │   └── main.yml
│       └── tasks
│           └── main.yml
└── templates
    └── test_template.j2
~~~

Playbook と それぞれのファイルの中身をみていきましょう。
まずは `test_role` および `test_role_2` の`main.yml` です。

~~~
roles/myns.myrole.test_role/tasks/main.yml
(roles/myns.myrole.test_role_2/tasks/main.yml も同内容)
---
- name: Copy file with relative path
  ansible.builtin.copy:
    src: test_file
    dest: /tmp/test_file

- name: Get File Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_file
  register: cat_test_file_result
  changed_when: false

- name: Use Templates with relative path
  ansible.builtin.template:
    src: test_template.j2
    dest: /tmp/test_template

- name: Get Template Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_template
  register: cat_test_template_result

- name: Print File Contents
  ansible.builtin.debug:
    msg: |
        File Contents:
        {{ cat_test_file_result.stdout }}
        
        Template Contents:
        {{ cat_test_template_result.stdout }}
...
~~~

単純に`ansible.builtin.copy`, `ansible.builtin.template` モジュールを使用し, 内容を出力させているだけです。

Playbook は下記の通り， 単純にRole を呼び出しているだけです。
~~~
playbook.yaml
---
- name: Test File and Template module demo
  hosts: localhost
  connection: local
  gather_facts: false
  roles:
    - myns.myrole.test_role
    - myns.myrole.test_role_2
...
~~~

次に， Role 内, Project root それぞれの`files/`, `templates/` ディレクトリ内のファイルをみてみましょう。
まずは Role (myns.myrole.test_role)の方です。
~~~
roles/myns.myrole.test_role/files/test_file

This is file in Role files/ directory!
~~~

~~~
roles/myns.myrole.test_role/templates/test_template.j2

This is Template in Role templates/ directory!
{{ foo }}
{{ hoge }}
~~~

Project root 直下のファイルは下記の通りです。
~~~
files/test_file

This is file in Role files/ directory!
~~~

~~~
templates/test_template.j2

This is file in Project's templates/ directory!
{{ foo }}
{{ hoge }}
~~~

それでは， Playbook を実行してみましょう。出力を見やすくするため，`ansible.cfg` 内で, 以下の行を追加しています。

~~~
ansible.cfg
[defaults]
callback_format_pretty = false
callback_result_format = yaml
~~~

~~~
$ ansible-playbook playbook.yaml 
PLAY [Test File and Template module demo] *************************************************************************************************************************************************************************************

TASK [myns.myrole.test_role : Debug] ******************************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: Execute Role Tasks

(略)

TASK [myns.myrole.test_role : Print File Contents] ****************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Role files/ directory!

      Template Contents:
      This is Template in Role templates/ directory!
      bar
      huga

(略)

TASK [myns.myrole.test_role_2 : Print File Contents] **************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Project's files/ directory!

      Template Contents:
      This is file in Project's templates/ directory!
      bar
      huga
~~~

`test_role_1` の実行にはRole ファイルが, `test_role_2` の実行では， Project root のファイルが使用されていることがわかります。


## 3. 実装

複数の実装を考えますが， いずれも ***絶対パスでsrcを指定する*** ことが重要です。
絶対パスであれば, role/playbook の親子関係による相対パスの位置を全て無視でます。

ただし, 絶対パスをハードコーディングしてしまうと, 移植性が低下し, Ansible Execution Environment を用いた実行を考慮する際などに余計なハードルが発生してしまいます。

そこで, Ansible の特殊変数の一つである, `ansible_search_path` を使用します。
この変数は後の例で見るように, 実行時のファイル探索（絶対）パスを順番に格納します。

### 3-1. 変数
まずは`src` パラメータを変数化してしまい， 実行時に選択する方法を見てみましょう。

デフォルト動作の確認で使用したRole やPlaybook を少し変更することで実装します。
Role 側のタスクは次の通りです。

~~~
---
- name: Print Search Path
  ansible.builtin.debug:
    var: ansible_search_path

- name: Copy file
  ansible.builtin.copy:
    src: "{{ file_src }}"
    dest: /tmp/test_file

- name: Get File Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_file
  register: cat_test_file_result
  changed_when: false

- name: Use Templates
  ansible.builtin.template:
    src: "{{ template_src }}"
    dest: /tmp/test_template

- name: Get Template Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_template
  register: cat_test_template_result

- name: Print File Contents
  ansible.builtin.debug:
    msg: |
        File Contents:
        {{ cat_test_file_result.stdout }}

        Template Contents:
        {{ cat_test_template_result.stdout }}
...
~~~

ご覧の通り， `ansible.builtin.copy`, `ansible.builtin.template` モジュールの`src`パラメータを変数で指定しています。

確認のため, `ansible_search_path` を出力するデバッグコードをはじめに挿入しています。

呼び出し側のコードは下記の通りです。

~~~
---
- name: Test File and Template module demo
  hosts: localhost
  connection: local
  gather_facts: false
  roles:
    - name: myns.myrole.test_role
      # Project Root のfiles/ , templates/ 以下の絶対パスを参照する
      vars:
        file_src: "{{ ansible_search_path[-1] }}/files/test_file"
        template_src: "{{ ansible_search_path[-1] }}/templates/test_template.j2"
      # Role 内のdefaults/main.yaml の変数に指定したパスを参照する。（こっちは相対パス）
    - name: myns.myrole.test_role
...
~~~

`ansible_search_path` の最後のアイテムが, 呼び出し側のPlaybook のディレクトリとなることを利用し， 変数を設定しています。

また, Role 内の`defaults/main.yml` に相対パスを設定することで, 変数を設定しなかった場合Role 内のfiles, templates を使用するようにしています。

~~~
file_src: test_file
template_src: test_template.j2
~~~

それでは実行してみましょう。

~~~
$ ansible-playbook playbook.yaml
PLAY [Test File and Template module demo] *************************************************************************************************************************************************************************************

TASK [myns.myrole.test_role : Print Search Path] ******************************************************************************************************************************************************************************
ok: [localhost] => 
    ansible_search_path:
    - /home/user/override_role_files/by_variables/roles/myns.myrole.test_role
    - /home/user/override_role_files/by_variables/roles/myns.myrole.test_role/tasks
    - /home/user/override_role_files/by_variables

（略)

TASK [myns.myrole.test_role : Print File Contents] ****************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Project's files/ directory!

      Template Contents:
      This is file in Project's templates/ directory!
      bar
      huga

(略)

TASK [myns.myrole.test_role : Print File Contents] ****************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Role files/ directory!

      Template Contents:
      This is Template in Role templates/ directory!
      bar
      huga

~~~

想定通り， 1回目のはPlaybook 側のファイルを, 2回目は Role 側のファイルを参照していることがわかります。


### 3-2. ファイルの存在チェックのロジックを実装する
次の方法は， 実行時にファイルの存在チェックを行い, Project Root 側のファイルが存在すれば, そちらを使用する判定条件を実装します。



~~~
---
- name: Print Search Path
  ansible.builtin.debug:
    var: ansible_search_path

- name: Check Project Root File Existence
  ansible.builtin.stat:
    path: "{{ ansible_search_path[-1] }}/files/test_file"
  register: _file_stat
  delegate_to: localhost
  run_once: true

- name: Copy file if Project Root Files Exist
  ansible.builtin.copy:
    src: "{{ ansible_search_path[-1] }}/files/test_file"
    dest: /tmp/test_file
  when: _file_stat.stat.exists

- name: Copy file
  ansible.builtin.copy:
    src: test_file
    dest: /tmp/test_file
  when: not _file_stat.stat.exists

- name: Get File Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_file
  register: cat_test_file_result
  changed_when: false

- name: Check Project Root Templates Existence
  ansible.builtin.stat:
    path: "{{ ansible_search_path[-1] }}/templates/test_template.j2"
  register: _template_stat
  delegate_to: localhost
  run_once: true

- name: Deploy file if Project Root Files Exist
  ansible.builtin.template:
    src: "{{ ansible_search_path[-1] }}/templates/test_template.j2"
    dest: /tmp/test_template
  when: _template_stat.stat.exists

- name: Use Templates
  ansible.builtin.template:
    src: test_template.j2
    dest: /tmp/test_template
  when: not _template_stat.stat.exists

- name: Get Template Contents
  ansible.builtin.command:
    cmd: /bin/cat /tmp/test_template
  register: cat_test_template_result

- name: Print File Contents
  ansible.builtin.debug:
    msg: |
        File Contents:
        {{ cat_test_file_result.stdout }}

        Template Contents:
        {{ cat_test_template_result.stdout }}
...
~~~

2番目のタスクで, ローカルホストのProject Root 配下のファイルの存在チェックをしています。
この結果を使用し, 使用する`src` パラメータを分岐させています。`set_fact` を実行して変数に格納した方が読みやすいかもしれません。

~~~
（略)
- name: Set Fact
  ansible.builtin.set_fact:
    file_src: "{{ ansible_search_path[-1] }}/files/test_file"
  when: _file_stat.stat.exists

- name: Copy file
  ansible.builtin.copy:
    src: "{{ file_src | default('test_file') }}"
    dest: /tmp/test_file
(略)
~~~

それでは実行してみましょう。呼び出し側のコードは省略します。（単にRole を呼び出しているだけです。）

~~~
$ tree
├── ansible.cfg
├── files
│   └── test_file
├── inventory
├── playbook.yaml
├── roles
│   └── myns.myrole.test_role
│       ├── README.md
│       ├── defaults
│       │   └── main.yml
│       ├── files
│       │   └── test_file
│       ├── meta
│       │   └── main.yml
│       ├── tasks
│       │   └── main.yml
│       └── templates
│           └── test_template.j2
└── templates
    └── test_template.j2

$ ansible-playbook playbook.yaml
TASK [myns.myrole.test_role : Print Search Path] ******************************************************************************************************************************************************************************
ok: [localhost] => 
    ansible_search_path:
    - /home/user/override_role_files/by_existence_check/roles/myns.myrole.test_role
    - /home/user/override_role_files/by_existence_check/roles/myns.myrole.test_role/tasks
    - /home/user/override_role_files/by_existence_check

(略)

ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Project's files/ directory!

      Template Contents:
      This is file in Project's templates/ directory!
      bar
      huga
~~~

Project Root 側にファイルがある場合は, 想定通りですね。
それでは, Project Root 側にファイルがないパターンも実行してみましょう。

~~~
$ tree
.
├── ansible.cfg
├── inventory
├── playbook.yaml
└── roles
    └── myns.myrole.test_role
        ├── README.md
        ├── defaults
        │   └── main.yml
        ├── files
        │   └── test_file
        ├── meta
        │   └── main.yml
        ├── tasks
        │   └── main.yml
        └── templates
            └── test_template.j2

$ ansible-playbook playbook.yaml

TASK [myns.myrole.test_role : Print Search Path] ******************************************************************************************************************************************************************************
ok: [localhost] => 
    ansible_search_path:
    - /home/user/override_role_files/by_existence_check_not_exists/roles/myns.myrole.test_role
    - /home/user/override_role_files/by_existence_check_not_exists/roles/myns.myrole.test_role/tasks
    - /home/user/override_role_files/by_existence_check_not_exists

(略)

TASK [myns.myrole.test_role : Print File Contents] ****************************************************************************************************************************************************************************
ok: [localhost] => 
    msg: |
      File Contents:
      This is file in Role files/ directory!

      Template Contents:
      This is Template in Role templates/ directory!
      bar
      huga
~~~

想定どおり， Role 内のファイルが使われました。

## 4. 終わりに
以上， Role 内のデフォルトの`files/`, `templates/` を上書きする実装方法を紹介しました。
デフォルトの挙動を定義しつつ, ユーザーに上書きも許可したい, といった状況は割とよくあるのではないかと思います。

通常の挙動と異なるためドキュメントで明記することは必須ですが, 同様の状況への処方箋となれば幸いです。