2018年6月23日土曜日

ansible-playbook を Python からコールしてみた

概要

よくあるケースとしてはダイナミックにサーバをプロビジョニングしたい場合かなと思います
簡単な playbook を作成した後でプログラマブルに Ansible を実行してみます

環境

  • macOS 10.13.5
  • Python 2.7.15
  • Ansible 2.5.5

事前準備

今回は Mac で行っています
pip からインストールしました

  • pip install ansible

Homebrew でもインストールできるのでそれでも問題ないです

簡単な playbook の作成

非常に簡単なやつを作成します
ターゲットのサーバに対してファイルを touch するだけです
全体は以下の通りです

  • tree -a
.
├── production
├── roles
│   └── file
│       └── tasks
│           ├── main.yml
│           └── new.yml
└── site.yml
  • vim production
[ubuntu]
172.28.128.3

この Ubuntu サーバは vagrant を使ってローカルに立てています
サーバは何でも OK なので適当に用意してください

  • vim roles/file/tasks/main.yml
- include: new.yml
  • vim roles/file/tasks/new.yml
- file:
    path: /tmp/hoge
    state: touch

main.yml は参照するだけで本体は new.yml を作成してそっちに書いています
Ansible 側の内容はこれだけです

実行は

  • ansible-playbook -k -i production site.yml

です
-k オプションは SSH のパスワードを対話的に指定するためのオプションです

これで問題なく動作すれば playbook の準備は OK です

Tips

少し自分がはまった点を紹介します

fatal: [172.28.128.3]: FAILED! => {"changed": false, "module_stderr": "", "module_stdout": "/bin/sh: 1: /usr/bin/python: n
ot found\r\n", "msg": "MODULE FAILURE", "rc": 127}

で実行できない状態がありました
ターゲットのマシンである Ubuntu に Python をインストールしてあげることで解決しました
Vagrant の xenial を使っていると Python がデフォルトでインストールされていないようです
Ansible は基本的に 2.7 以上の Python が /usr/bin/python にインストールされている想定だそうです
https://docs.ansible.com/ansible/2.4/faq.html

一応 Python なしでも実行する方法はあるらしいのですが基本はいるようです

Ansible を Python からキックするスクリプトの作成

ここからが本番です
先ほど作成した playbook と同じことを Python から実行してみます
結構長いのであとで詳細を説明します
改行部分がまとまりとなっています

  • vim site.py
import json
import shutil
from collections import namedtuple
from ansible.parsing.dataloader import DataLoader
from ansible.vars.manager import VariableManager
from ansible.inventory.manager import InventoryManager
from ansible.playbook.play import Play
from ansible.executor.task_queue_manager import TaskQueueManager
from ansible.plugins.callback import CallbackBase
import ansible.constants as C

class ResultCallback(CallbackBase):
    def v2_runner_on_ok(self, result, **kwargs):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

    def v2_runner_on_failed(self, result, *args, **kwargs):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

    def v2_runner_on_unreachable(self, result):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

Options = namedtuple('Options', ['connection', 'module_path', 'forks', 'become', 'become_method', 'become_user', 'check', 'diff', 'remote_user'])
options = Options(connection='paramiko', module_path=['/usr/share/ansible'], forks=10, become=None, become_method=None, become_user=None, check=False, diff=False, remote_user='root')

loader = DataLoader()
results_callback = ResultCallback()
host_list = ['./production']
sources = ','.join(host_list)
inventory = InventoryManager(loader=loader, sources=host_list)
variable_manager = VariableManager(loader=loader, inventory=inventory)

play_source =  dict(
    name = "Ansible Play",
    hosts = ['ubuntu'],
    # gather_facts = 'yes',
    gather_facts = 'no',
    tasks = [
        dict(action=dict(module='file', args=dict(path='/tmp/hoge', state='touch')))
    ]
)
play = Play().load(play_source, variable_manager=variable_manager, loader=loader)

tqm = None
passwords = dict()
try:
    tqm = TaskQueueManager(
              inventory=inventory,
              variable_manager=variable_manager,
              loader=loader,
              options=options,
              passwords=passwords,
              stdout_callback=results_callback,
          )
    result = tqm.run(play)
finally:
    if tqm is not None:
        tqm.cleanup()
    shutil.rmtree(C.DEFAULT_LOCAL_TMP, True)

まず全体的な流れとして

  • 必要なライブラリの読み込み -> コールバック処理の定義 -> オプションの設定 -> インベントリ情報の生成 -> playbook の定義 -> 実行

という感じになります
それぞれ順を追って説明します

詳細説明

まず import 系ですがこれはほぼすべて必須かなと思います

import json
import shutil
from collections import namedtuple
from ansible.parsing.dataloader import DataLoader
from ansible.vars.manager import VariableManager
from ansible.inventory.manager import InventoryManager
from ansible.playbook.play import Play
from ansible.executor.task_queue_manager import TaskQueueManager
from ansible.plugins.callback import CallbackBase
import ansible.constants as C

各流れの中で使用するものになります
次にコールバック用のクラスを先に作成します

class ResultCallback(CallbackBase):
    def v2_runner_on_ok(self, result, **kwargs):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

    def v2_runner_on_failed(self, result, *args, **kwargs):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

    def v2_runner_on_unreachable(self, result):
        host = result._host
        print(json.dumps({host.name: result._result}, indent=4))

results_callback = ResultCallback()

コールバッククラスは何に使うかというと単純に playbook が終了した際に何をするか定義することができます
今回は「成功」「失敗」「アクセスできない」の 3 つのイベントをハンドリングして内容を表示しているだけです
ここで定義したコールバッククラスはあとで使用します

次にオプションの設定です
これは CLI を実行する際のオプションと同じですが Python からコールする際には必須のオプションがあります

Options = namedtuple('Options', ['connection', 'module_path', 'forks', 'become', 'become_method', 'become_user', 'check', 'diff', 'remote_user'])
options = Options(connection='paramiko', module_path=['/usr/share/ansible'], forks=10, become=None, become_method=None, become_user=None, check=False, diff=False, remote_user='root')

最後の remote_user だけ追加しました
それ以外は公式で紹介していた部分になるので必須かなと思います
例えば ansible-playbook コマンドでオプションを指定する必要がある場合はここで追加します
今回の場合であれば -k-i ですがこれらは InventroyManager で定義することになります (ややこしい)

次に InventoryManager になります

loader = DataLoader()
host_list = ['./production']
sources = ','.join(host_list)
inventory = InventoryManager(loader=loader, sources=host_list)
variable_manager = VariableManager(loader=loader, inventory=inventory)

ポイントは定義したインベントリファイル (production) を読み込んでいる部分です
実はファイルを使わないでも直接ここでインベントリ情報を定義することができます
が実はファイルを使ったほうが理由があります
それは SSH 時のパスワードを定義するためです
先ほどの production から実は以下のように変更しています

  • vim production
[ubuntu]
172.28.128.3

[ubuntu:vars]
ansible_ssh_user='root'
ansible_ssh_pass='xxxxxxxxxx'

Python から実行する際に -k を使って対話的に実行することはできません
なので、パスワード情報を事前にインベントリファイルに定義しておく必要があります
このパスワード情報を InventoryManager に食わせる方法がファイルからしかやり方がわからなかったため、既存のインベントリファイルを使うようにしています

次に playbook の定義です

play_source =  dict(
    name = "Ansible Play",
    hosts = ['ubuntu'],
    # gather_facts = 'yes',
    gather_facts = 'no',
    tasks = [
        dict(action=dict(module='file', args=dict(path='/tmp/hoge', state='touch')))
    ]
)
play = Play().load(play_source, variable_manager=variable_manager, loader=loader)

ポイントは hosts と tasks になります
hosts ではインベントリファイルに書かれているホスト情報のうちどれを実行するかを配列で定義できます
今回は 1 台だけなのでインベントリファイルに書かれているホストを 1 台定義しています
tasks ですが、ここにメインとなるプロビジョニング処理を定義する必要があります
tasks は先ほどのインベントリファイルとは違いファイルを流用することができません (今回の方法だとないだけで一応ファイルから実行する方法もあるっぽいです PlaybookExecutor ?)
なので、ここで同じ内容を定義し直す必要があります
今回であれば file モジュールを使って新規でファイルを touch しているだけなのでそこまでコードにするのは難しくありません
あとは Play().load() でこれまでに定義した情報を食わせれば playbook が出来上がります

最後に実行部分です

tqm = None
passwords = dict()
try:
    tqm = TaskQueueManager(
              inventory=inventory,
              variable_manager=variable_manager,
              loader=loader,
              options=options,
              passwords=passwords,
              stdout_callback=results_callback,
          )
    result = tqm.run(play)
finally:
    if tqm is not None:
        tqm.cleanup()
    shutil.rmtree(C.DEFAULT_LOCAL_TMP, True)

実行には TaskQueueManager を使います
これを使うことで実行結果をコールバックとして設定することができます
基本は定義してものを食わせていけば OK です
あとは run() するだけです

動作確認

  • python site.py

と実行すれば OK です
結果は以下のような感じで JSON で表示されます

{
    "172.28.128.3": {
        "_ansible_parsed": true, 
        "group": "root", 
        "uid": 0, 
        "dest": "/tmp/hoge", 
        "changed": true, 
        "state": "file", 
        "gid": 0, 
        "mode": "0644", 
        "invocation": {
            "module_args": {
                "directory_mode": null, 
                "force": false, 
                "remote_src": null, 
                "path": "/tmp/hoge", 
                "owner": null, 
                "follow": true, 
                "group": null, 
                "unsafe_writes": null, 
                "state": "touch", 
                "content": null, 
                "serole": null, 
                "diff_peek": null, 
                "setype": null, 
                "selevel": null, 
                "original_basename": null, 
                "regexp": null, 
                "validate": null, 
                "src": null, 
                "seuser": null, 
                "recurse": false, 
                "delimiter": null, 
                "mode": null, 
                "attributes": null, 
                "backup": null
            }
        }, 
        "owner": "root", 
        "diff": {
            "after": {
                "path": "/tmp/hoge", 
                "state": "touch"
            }, 
            "before": {
                "path": "/tmp/hoge", 
                "state": "absent"
            }
        }, 
        "size": 0, 
        "_ansible_no_log": false
    }
}

ターゲットのサーバに入ってファイルができているかも確認すると良いかもしれません
一応これで Python から同じ内容の playbook を実行することはできました

最後に

ansible-playbook を Python から実行してみました
正直辛い感じを覚えました
今回のやり方は公式に書いてあるサンプルを元にしているので、おそらく王道だと思います

一番辛いのは情報が少ないことです
タスクの定義の仕方などは各モジュールごとにいろいろと違うと思いますが、それをコードに落とす方法は Try&Error を繰り返すしかないかと思います
これをやりたいケースとしては既存の playbook をプログラミングから実行したい場合が一番多いかなと思います
その場合に今回の方法だと既存の playbook が使い回せないのが辛い点かなと思います

playbook を作る際にはじめからこの方法を取るのであれば何とかなるかもしれません
ただ今回紹介した構成はかなりミニマムな構成なのでここから group_vars やタスクの templates などが絡んでくると思うので、その場合にどうするかも調査しないとダメだと思います

他にやり方がないかもう少し見てみようと思います

参考サイト

0 件のコメント:

コメントを投稿