うさラボ

お勉強と備忘録

ntc-template自作入門

ntc-templateにほしいパーサーがなかったので自作入門してみました。

やりたかったこと

show ip nat translationsを実行したときに想定通りのStaticNATのエントリーが見たい

準備

環境
python

ライブラリ

  • textfsm==1.1.2
  • ntc-templates==2.1.0

公式ドキュメントを参考に準備を開始 github.com

自作のテンプレートを格納するディレクトを作成 /templates

テンプレートの名前は下記の規則で作ってねと説明ありました。
{{ vendor_os }}_{{ command_with_underscores }}.textfsm

今回はiosのshow ip nat translationsの結果をパースするテンプレートのため、 ほかのテンプレートを参考にcisco_ios_show_ip_nat_translations.textfsmとしました。

実装

テンプレートの作成を始めます。

今回想定しているshow ip nat translationsの結果が以下になります。
StaticNATのInside globalとInside locaが想定通りであるか?の確認をするためにパースします。
StaticNATのエントリとしては最後の行が該当します。

  Pro Inside global      Inside local       Outside local      Outside global
  tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
  tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21
  tcp 10.9.0.0:56384     10.1.0.2:56384     10.2.0.2:22        10.2.0.2:22
  icmp 10.9.0.0:56111     10.1.0.2:56384     10.2.0.2:23        10.2.0.2:23
  --- 10.9.0.0     10.1.0.2     ---        ---

ntc-template(textfsm)で対象の文字列を取得するのは正規表現でマッチさせる必要があります。
正規表現の書き方を確認するには下記サイトなどを利用しています。(機微な情報は載せないように気を付けてください) regex101.com

正規表現やほかのテンプレートを参考に下記テンプレートを作成しました

Value PROTOCOL (tcp|udp|icmp|---)
Value INSIDE_GLOBAL (\S+)
Value INSIDE_LOCAL (\S+)
Value OUTSIDE_LOCAL (\S+)
Value OUTSIDE_GLOBAL (\S+)

Start
  ^${PROTOCOL}\s+${INSIDE_GLOBAL}\s+${INSIDE_LOCAL}\s+${OUTSIDE_LOCAL}\s+${OUTSIDE_GLOBAL} -> Record

Valueにはパース後のキーを定義します。
key名と(ヒットさせる条件)の間にはスペースが必要です。 Value key名 (ヒットさせる条件)

テキストの解析処理はStartから始まります。
今回は^${PROTOCOL}\s・・・をキーに解析を始めます。
PROTOCOLはtcpudpかicmpか---が入る想定なのでカラムを除いたステータスの部分にマッチします。 f:id:usage_automate:20211111205641p:plain

そのほかのINSIDE_GLOBALなどは(\S+)のルールで空白以外の文字列をマッチさせています。
Pro Inside global Inside local Outside local Outside globalのカラムもマッチしていますがパース対象の文字列として含まれないように調整しています。 f:id:usage_automate:20211111212050p:plain

本来であれば、PATしている情報(IP:Portなどを値)も扱いやすいようにIPとPort分けて保存したほうがいいかもしれませんが、StaticNATの確認はこのテンプレートで十分できるのでここで終了します。

indexに今回追加したテンプレートを呼び出すルールを記載します。
これが必要なことに気づかず時間を結構食ってしまいました。。 templates/index

Template, Hostname, Platform, Command

cisco_ios_show_ip_nat_translations.textfsm, .*, cisco_ios, sh[[ow]] ip nat translations

確認

作成したテンプレートを動かすPythonスクリプトを作成します。

import os
import pprint
from ntc_templates.parse import parse_output # (1)

os.environ["NTC_TEMPLATES_DIR"] = "./templates" # (2)

output1 = (
  "Pro Inside global      Inside local       Outside local      Outside global\n"
  "tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21\n"
  "tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21\n"
  "tcp 10.9.0.0:56384     10.1.0.2:56384     10.2.0.2:22        10.2.0.2:22\n"
  "icmp 10.9.0.0:56111     10.1.0.2:56384     10.2.0.2:23        10.2.0.2:23\n"
  "--- 10.9.0.0     10.1.0.2     ---        ---\n"
) # (3)

parsed = parse_output(platform="cisco_ios",
                      command="show ip nat translations",
                      data=output1) # (4)

pprint.pprint(parsed) # (5)

1) ntc_templates.parseにあるparse_outputを読み込みます。
2) テンプレートを格納しているディレクトを指定します。
3) 想定のアウトプットを格納します。
4) パース処理を実行します、platform,command,dataを定義します。
5) 実行結果を表示します。

スクリプトを実行します。

sandbox# python nat_parser.py 
[{'inside_global': '10.9.0.0:51776',
  'inside_local': '10.1.0.2:51776',
  'outside_global': '10.2.0.2:21',
  'outside_local': '10.2.0.2:21',
  'protocol': 'tcp'},
 {'inside_global': '10.9.0.0:51778',
  'inside_local': '10.1.0.2:51778',
  'outside_global': '10.2.0.2:21',
  'outside_local': '10.2.0.2:21',
  'protocol': 'tcp'},
 {'inside_global': '10.9.0.0:56384',
  'inside_local': '10.1.0.2:56384',
  'outside_global': '10.2.0.2:22',
  'outside_local': '10.2.0.2:22',
  'protocol': 'tcp'},
 {'inside_global': '10.9.0.0:56111',
  'inside_local': '10.1.0.2:56384',
  'outside_global': '10.2.0.2:23',
  'outside_local': '10.2.0.2:23',
  'protocol': 'icmp'},
 {'inside_global': '10.9.0.0',
  'inside_local': '10.1.0.2',
  'outside_global': '---',
  'outside_local': '---',
  'protocol': '---'}]

無事にパースができました。
StaticNATのエントリはprotocl/outside_global/outside_localが---になるので下記の要素を確認します。

 {'inside_global': '10.9.0.0',
  'inside_local': '10.1.0.2',
  'outside_global': '---',
  'outside_local': '---',
  'protocol': '---'}]

パースがうまくできているので後はassertなどで結果の判断が簡単にできます。

まとめ

簡単なテンプレートでしたが、ntc-templateを自作することができました。
今回やったことは本当に入門レベルでntc-templateは複雑なテキストをパースすることも可能です。

自作したntc-templateをansibleに読み込ませてわちゃわちゃしたいと思います。

また、TTPパーサーも自作をしたのでいつか記事にします。

ansible-builderとansible-runnerを試してみた

はじめに

anible-runnerとansible-builderは名前こそ聞いたことがあったが触ったことがありませんでした。
Pythonスクリプトからansibleを動かしたいなと考えたときに、ansible-runnerが使えるという情報をいただいたので入門してみました。
まずは手始めにansible-builderで作成したコンテナをansible-runnerで動かすのをやってみようと思います。

ansible-builder

Ansible BuilderはAnsibleの実行環境をコンテナで用意するためのPythonモジュール

ansible-runner

Ansible Runnerは、コンテナを介してPlaybookを起動したり
Pythonスクリプトなどに組み込むことで直接Ansibleの起動が可能になるPythonモジュール

前提条件

コンテナの実行環境を事前に準備しておく必要があります

  • Docker
  • Podman

私は事前にDocekrをインストールしておきました。

インストール

ansible-builder/ansible-runnerはpipでインストールが可能です。

$ pip install ansible-builder
$ pip install ansible-runner

Anibleのインストールは不要です

$ pip freeze | grep ansible

ansible-builder==1.0.1
ansible-runner==2.0.1

Build環境の準備

まず、初めにansible-builderを使って実行環境のコンテナ(execution-environment)の設定を定義したファイルを作成します

execution-environment.yml

---
version: 1.1
dependencies:
  galaxy: requirements.yml
  python: requirements.txt

additional_build_steps:
  prepend: |
    RUN pip3 install --upgrade pip setuptools
  append:
    - RUN ls -la /etc

dependenciesのgalaxy/pythonで定義したrequirementsファイルを作成していきます additional_build_stepsでBuild時に実施するコマンドを定義できます
(ここでansibleを削除し、任意のVersionをインストールしようとしましたが失敗してしまいました、、本筋ではないので省略します)

pythonのライブラリをインストールするためにrequirements.txtを作成する(中身は適当です)

requirements.txt.yml

paramiko
jmespath
netaddr

インストールするCollectionなどを記載するrequirements.ymlを作成します

requirements.yml

---
collections:
  - name: ansible.netcommon
  - name: ansible.utils

Build

事前のDocker Imagesを確認

$ docker images
REPOSITORY   TAG       IMAGE ID   CREATED   SIZE

Build(数分かかります)

$ ansible-builder build --tag ee001
Running command:
  docker build -f context/Dockerfile -t ee001 context
Complete! The build context can be found at: /home/xx/xx/context

オプション--verbosityを利用することでログを表示が可能です

また、コマンド実行時にcontextディレクトが作成されDockerfileやrequirements.txtなどが格納されます
--contextを指定することで任意のPATHに作成が可能です

--build-arg EE_BASE_IMAGEオプションを指定するとpullするイメージファイルを選べるようです
※ただし、初めからansibleが入っているimageじゃないと動かなさそう・・

オプション付きBuild

$ ansible-builder build --tag ee002 --verbosity 3 --context tmp
Ansible Builder is building your execution environment image, "ee002".
File tmp/_build/requirements.yml will be created.
File tmp/_build/requirements.txt will be created.
File tmp/_build/ansible.cfg will be created.
Rewriting Containerfile to capture collection requirements
Running command:
  docker build -f tmp/Dockerfile -t ee002 tmp
Sending build context to Docker daemon  6.656kB
Step 1/22 : ARG EE_BASE_IMAGE=quay.io/ansible/ansible-runner:latest
Step 2/22 : ARG EE_BUILDER_IMAGE=quay.io/ansible/ansible-builder:latest
Step 3/22 : FROM $EE_BASE_IMAGE as galaxy
 ---> 7f28c304a37a
Step 4/22 : ARG ANSIBLE_GALAXY_CLI_COLLECTION_OPTS=
 ---> Using cache
 ---> 817d228223c7
Step 5/22 : USER root
 ---> Using cache
 ---> abe073792147
Step 6/22 : ADD _build/ansible.cfg ~/.ansible.cfg
 ---> Using cache
 ---> 86f235ef5837
Step 7/22 : ADD _build /build
 ---> Using cache
 ---> 1d4ae1669f34
Step 8/22 : WORKDIR /build
 ---> Using cache
 ---> 3222fad0e2e6
Step 9/22 : RUN ansible-galaxy role install -r requirements.yml --roles-path /usr/share/ansible/roles
 ---> Using cache
 ---> c43f05572389
Step 10/22 : RUN ansible-galaxy collection install $ANSIBLE_GALAXY_CLI_COLLECTION_OPTS -r requirements.yml --collections-path /usr/share/ansible/collections
 ---> Using cache
 ---> 714d46cd3e58
Step 11/22 : FROM $EE_BUILDER_IMAGE as builder
 ---> 5806c16c9ae2
Step 12/22 : COPY --from=galaxy /usr/share/ansible /usr/share/ansible
 ---> Using cache
 ---> 6348ad77818a
Step 13/22 : ADD _build/requirements.txt requirements.txt
 ---> Using cache
 ---> 555ce006fb1c
Step 14/22 : RUN ansible-builder introspect --sanitize --user-pip=requirements.txt --write-bindep=/tmp/src/bindep.txt --write-pip=/tmp/src/requirements.txt
 ---> Using cache
 ---> 29d5ad112a18
Step 15/22 : RUN assemble
 ---> Using cache
 ---> 2bcba19d4f70
Step 16/22 : FROM $EE_BASE_IMAGE
 ---> 7f28c304a37a
Step 17/22 : USER root
 ---> Using cache
 ---> a8ea1ddb1c81
Step 18/22 : RUN pip3 install --upgrade pip setuptools
 ---> Using cache
 ---> 342d9b393f12
Step 19/22 : COPY --from=galaxy /usr/share/ansible /usr/share/ansible
 ---> Using cache
 ---> 5a355f923a51
Step 20/22 : COPY --from=builder /output/ /output/
 ---> Using cache
 ---> 2f14d46eac3e
Step 21/22 : RUN /output/install-from-bindep && rm -rf /output/wheels
 ---> Using cache
 ---> b86981d1e55f
Step 22/22 : RUN ls -la /etc
 ---> Using cache
 ---> e94c237fcda9
Successfully built e94c237fcda9
Successfully tagged ee002:latest

Complete! The build context can be found at: /home/xx/xx/tmp

tmpにDokcerfileなどが格納されます

imagesを確認すると、指定したtagでimageが作成されたことが確認できます
(ついでにいろいろ増えているのはよくわからないですが、、、)

$ docker images
REPOSITORY                        TAG       IMAGE ID       CREATED         SIZE
ee001                             latest    e94c237fcda9   4 minutes ago   741MB
ee002                             latest    e94c237fcda9   4 minutes ago   741MB
<none>                            <none>    2bcba19d4f70   4 minutes ago   779MB
<none>                            <none>    714d46cd3e58   6 minutes ago   708MB
quay.io/ansible/ansible-runner    latest    7f28c304a37a   8 hours ago     703MB
quay.io/ansible/ansible-builder   latest    5806c16c9ae2   8 hours ago     611MB

runner環境準備

imageができたのでansible-runnerを動かしてみます

ansible-runnerは利用前に下記2個のディレクトリを作成します

  • env: runnerの実行のために必要な設定を格納する
  • project: playbookやinventoryを格納する

ansible-runnerを実行時には/projectsの配下を見に行くようで、projectsディレクトリを作成しその配下にPlaybookを配置する必要があるようです

xxx
├── env
│   └── settings
└── project
    └── playbooks
        └── sample001.yml

env/settingsで実行対象のimagesファイルの指定などを行っています process_isolationはプレイブックを実行するファイルシステム上のどのディレクトリにアクセスできるかを制限するものらしいです
(Towerでいうところの分離されたジョブってところがイメージ近いかな?)

container_image: ee001  <--ここでBuilderで作成したImageを指定
process_isolation_executable: docker
process_isolation: true

ここで実行対象のコンテナイメージを指定しなかった場合quay.io/ansible/ansible-runner:deveというimageが自動的にダウンロードされ実行されます
runner実行時に--container-imageオプションを使っても指定可能です。
settingに対象イメージを書いていると--container-imageオプションよりも優先されるような動きをしました

用意したPlaybookはLocalhostに対してコマンドを実行するシンプルなものです
(おまけで最後にcollectionsで追加したnetcommonが使えるか確認しています)

sample001.yml

---
- hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: send command
      command: ansible --version
      register: res_version

    - name: debug version
      debug:
        msg: "{{ res_version.stdout_lines }}"

    - name: send command
      command: ansible-galaxy collection list
      register: res_collections

    - name: debug version
      debug:
        msg: "{{ res_collections.stdout_lines }}"

    - name: send command
      command: pip freeze
      register: res_pip_freeze

    - name: debug version
      debug:
        msg: "{{ res_pip_freeze.stdout_lines }}"

    - name: debug ip
      debug:
        msg: "{{ ip | ansible.netcommon.ipv4 }}"
      vars:
        ip: '192.168.1.1/32'

runner実行

それでは実行をしてみます ansible-runner run コマンドで実行します runの後はprojectが格納されているディレクトリを指定し、-pでplaybookを指定します
runnerはprojectディレクトリにあるファイルを参照するので、少し注意が必要です。
ここで-p project/playbooks/sample001.ymlのようにパスを指定してしまうと失敗してしまいます。

$ ansible-runner run . -p playbooks/sample001.yml
[WARNING]: Unable to parse /runner/inventory/hosts as an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that
the implicit localhost does not match 'all'

PLAY [localhost] ***************************************************************

TASK [send command1] ***********************************************************
changed: [localhost]

TASK [debug version] ***********************************************************
ok: [localhost] => {
    "msg": [
        "ansible [core 2.11.3rc1.post0] ",
        "  config file = None",
        "  configured module search path = ['/home/runner/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']",
        "  ansible python module location = /usr/local/lib/python3.8/site-packages/ansible",
        "  ansible collection location = /home/runner/.ansible/collections:/usr/share/ansible/collections",
        "  executable location = /usr/local/bin/ansible",
        "  python version = 3.8.6 (default, Jan 29 2021, 17:38:16) [GCC 8.4.1 20200928 (Red Hat 8.4.1-1)]",
        "  jinja version = 2.10.3",
        "  libyaml = True"
    ]
}

TASK [send command2] ***********************************************************
changed: [localhost]

TASK [debug collection] ********************************************************
ok: [localhost] => {
    "msg": [
        "",
        "# /usr/share/ansible/collections/ansible_collections",
        "Collection        Version",
        "----------------- -------",
        "ansible.netcommon 2.2.0  ",
        "ansible.utils     2.3.0  "
    ]
}

TASK [send command3] ***********************************************************
changed: [localhost]

TASK [debug pip freeze] ********************************************************
ok: [localhost] => {
    "msg": [
        "ansible-core @ file:///output/wheels/ansible_core-2.11.3rc1.post0-py3-none-any.whl",
        "ansible-pylibssh==0.2.0",
        "ansible-runner @ file:///output/wheels/ansible_runner-2.0.0.0a4.dev61-py3-none-any.whl",
        "asn1crypto==1.2.0",
        "attrs==21.2.0",
        "Babel==2.7.0",
        "bcrypt==3.2.0",
        "cffi==1.13.2",
        "chardet==3.0.4",
        "cryptography==2.8",
        "decorator==5.0.9",
        "docutils==0.17.1",
        "dumb-init==1.2.5",
        "future==0.18.2",
        "gssapi==1.6.14",
        "idna==2.8",
        "Jinja2==2.10.3",
        "jmespath==0.10.0",
        "jsonschema==3.2.0",
        "jxmlease==1.0.3",
        "lockfile==0.12.2",
        "lxml==4.4.1",
        "MarkupSafe==1.1.1",
        "ncclient==0.6.12",
        "netaddr==0.8.0",
        "ntlm-auth==1.5.0",
        "packaging==21.0",
        "paramiko==2.7.2",
        "pexpect==4.8.0",
        "ply==3.11",
        "ptyprocess==0.7.0",
        "pyasn1==0.4.8",
        "pycparser==2.19",
        "pykerberos==1.2.1",
        "PyNaCl==1.4.0",
        "pyOpenSSL==19.1.0",
        "pyparsing==2.4.7",
        "pypsrp==0.5.0",
        "pyrsistent==0.18.0",
        "PySocks==1.7.1",
        "pyspnego==0.1.6",
        "python-daemon==2.3.0",
        "pytz==2019.3",
        "pywinrm==0.4.2",
        "PyYAML==5.4.1",
        "requests==2.22.0",
        "requests-credssp==1.2.0",
        "requests-ntlm==1.1.0",
        "resolvelib==0.5.4",
        "six==1.12.0",
        "textfsm==1.1.2",
        "toml==0.10.2",
        "ttp==0.7.1",
        "urllib3==1.25.7",
        "xmltodict==0.12.0"
    ]
}

TASK [debug ip] ****************************************************************
ok: [localhost] => {
    "msg": "192.168.1.1/32"
}

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

正常に完了しました
完了すると、artifactsディレクトリが生成され、その中に実行結果やjob_eventが表示されます
stdoutを見るとAnsibleで実行した結果が見れるのですが、色コード?のようなものが入ってしまって見にくいです。。

f:id:usage_automate:20210715174142p:plain

まとめ

ansible-builderとansible-runnerに入門してみました
コンテナイメージを作成しておいて、実行時だけ起動するというのはとてもスマートだなと感じました
venvを切ってライブラリを・・・と今まで当たり前のようにやっていましたが、builderとrunnerを利用して簡単に環境のセットアップができるようになれば良いなと思います。

いまいちわからなかったのが、ansibleを任意のVersionで実行したい場合。。こちら継続で調べたいと思います・・

次回はPythonスクリプトにansible-runnerを組み込んでみる

公式Docs

ansible-runner.readthedocs.io ansible-builder.readthedocs.io

AnsibleでNetboxを触るときのTIPS

Netboxのおさらい

IPAM機能(IP/PREFIX/VLAN/VRF)とDCIM機能(Device/Rack/Power/Cable) を兼ねそろえたOSSのWebツール
できることは以下

  • IPアドレス管理(IPAM) -IPネットワークとアドレス、VRF、およびVLAN
  • 機器ラック-グループおよびサイトごとに整理
  • バイス-デバイスの種類とインストール場所
  • 接続-デバイス間のネットワーク、コンソール、および電源接続
  • 仮想化-仮想マシンクラスタ
  • データ回線-長距離通信回線およびプロバイダー
  • シークレット-機密性の高いクレデンシャルの暗号化されたストレージ

Netboxでできないこと

スコープ外の機能、ほかのツールと組み合わせて使うよいもの

  • 構成管理
  • DNSサーバー
  • RADIUSサーバー
  • 構成管理
  • 要員派遣

構成管理はlocal_contextなど使えばできないこともなさそうだなぁとは思ったり

リポジトリ
github.com

ドキュメント
netbox.readthedocs.io

環境

Netbox 2.10 (https://netboxdemo.com/)デモ環境
Ansible 2.10
pynetbox 5.3.1

準備

  • pynetboxのライブラリが必要なのでインストール pip install pynetbox 実施
  • netbox collectionのインストール ansible-galaxy collection install netbox.netbox 実施
  • netboxでAPIトークンを作成
    f:id:usage_automate:20210426215341p:plain

  • URLとTokenは変数として格納しておく(netbox_url/netbox_token)

自動払い出し機能付きモジュールの使い方

netbox_prefix

sampleのタスクを下記に記載
ポイントは parentfirst_available
親セグメントから余りのprefixを払い出してくれる。
(192.168.0.0/24から/28を払い出す)

- name: create prefix
  netbox.netbox.netbox_prefix:
    netbox_url: "{{ netbox_url }}"
    netbox_token: "{{ netbox_token }}"
    validate_certs: false
    data:
      parent: "{{ prefix }}"
      prefix_length: "{{ prefix_length }}"
    state: present
    first_available: true
  vars:
    prefix: 192.168.0.0/24
    prefix_length: 28

ちなみに払い出せない場合はSKIPになる

netbox_ip_address

sampleplaybookを下記に記載
こちらは state: new にすることでprefix内からipaddressを払い出してくれる

- name: "Address payout "
  netbox.netbox.netbox_ip_address:
    netbox_url: "{{ netbox_url }}"
    netbox_token: "{{ netbox_token }}"
    validate_certs: false
    data:
      prefix: "{{ prefix }}"
    state: new
  vars:
    prefix: 192.168.0.0/24

設定更新時に使えるquery_params

netbox_ip_addressは特に意識せずに使ってしまうと重複して登録ができてしまう場合があります
(VIPなど、複数DeviceにIPを紐付ける場合があったりするためそういった仕様なのかもしれないです)

それでは困る場合もありますが、そんな時に使えるのがquery_paramsです
ユニークなオブジェクトに対して処理を実行できます
使い方

- name: ip address assinge
   netbox.netbox.netbox_ip_address:
     netbox_url: "{{ netbox_url }}"
     netbox_token: "{{ netbox_token }}"
     data:
       address: "192.168.0.1/32"
       assigned_object:
          name: "Gi1/0/1"
          device: "device_a"
     query_params:
       - address

便利なnb_lookup

netboxのcollectionにはlookup pluginも用意されています
めちゃめちゃ便利なnb_lookupを紹介します

使い方

- name: fetch prefixes
   set_fact:
      prefixes: "{{ q('netbox.netbox.nb_lookup', 'prefixes',
                             api_endpoint=netbox_url,
                             api_filter=filter_value,
                             token=netbox_token)}}"
    vars:
      filter_value: 'mask_length=24'

prefixes の部分は使えるkeywordが決まっている、書き換えるといろいろなリソースにアクセスすることができる
`api_filter‘はクエリする際のパラメータ部分になります

上記例では/24のPrefixのみを取得できる
ちなみに、パラメータを増やす際はスペースで区切る

実行するとkeyとvalueが返ってくる、valueの中に細かい情報が入っています
f:id:usage_automate:20210426213527p:plain

nb_lookupで使えるkeyword

  • aggregates
  • circuit-terminations
  • circuit-types
  • circuits
  • circuit-providers
  • cables
  • cluster-groups
  • cluster-types
  • clusters
  • config-contexts
  • console-connections
  • console-ports
  • console-server-port-templates
  • console-server-ports
  • device-bay-templates
  • device-bays
  • device-roles
  • device-types
  • devices
  • export-templates
  • front-port-templates
  • front-ports
  • graphs
  • image-attachments
  • interface-connections
  • interface-templates
  • interfaces
  • inventory-items
  • ip-addresses
  • manufacturers
  • object-changes
  • platforms
  • power-connections
  • power-outlet-templates
  • power-outlets
  • power-port-templates
  • power-ports
  • prefixes
  • rack-groups
  • rack-reservations
  • rack-roles
  • racks
  • rear-port-templates
  • rear-ports
  • regions
  • reports
  • rirs
  • roles
  • secret-roles
  • secrets
  • services
  • sites
  • tags
  • tenant-groups
  • tenants
  • topology-maps
  • virtual-chassis
  • virtual-machines
  • virtualization-interfaces
  • vlan-groups
  • vlans
  • vrfs

github.com

まとめ

何かと便利なNetboxですがAnsibleで操作するさいに忘れがちなことをまとめてみました。

※ダイナミックインベントリは試せてないのでまたいつか・・

AnsibleのXMLモジュールお試ししてみた

XMLモジュールお試ししたい

NW機器は何かとXMLを使うことがあります (ACIやPaloaltoなど)

お勉強がてらにAnsibleでXMLを操作を試してみます
xmlのファイルから対象の情報を抜き取ることがゴールです。

下準備

collectionのダウンロード

community.general 2.4.0

pythonライブラリのダウンロード

lxml==4.6.3

サンプルPlaybook

下記XPathの紹介ページからコピペしてきた、XMLxml_dataに格納しています

https://www.w3schools.com/xml/xpath_nodes.asp

---
- hosts: localhost
  connection: local
  gather_facts: false
  vars:
    xml_data: |
      <?xml version="1.0" encoding="UTF-8"?>
      <bookstore>
        <book>
          <title lang="en">Harry Potter</title>
          <author>J K. Rowling</author>
          <year>2005</year>
          <price>29.99</price>
        </book>
      </bookstore>

  tasks:
    - name: community.general.xml
      community.general.xml:
        xmlstring: "{{ xml_data }}"
        xpath: /bookstore/book/title
        content: text
      register: hits

    - name: debug
      ansible.builtin.debug:
        msg: "{{ hits }}"

community.general.xmlモジュールを利用します、変数でxmlを渡すときはxmlstringを利用します。
xmlファイルを直接開きたい場合はpathで対象のxmlファイルを選択します

xpathで抽出したいデータがある階層を指定し、 contentをtextにし<title></title>で囲われているTextを抜き出します。

<title lang="en">Harry Potter</title>

実行結果はhits に格納します

実行

実行してみます

f:id:usage_automate:20210414221636p:plain 成功しました
community.general.xmlの実行結果はmatchesに含まれるようでしたのでPlaybook微修正して余計な出力を減らします

    - name: debug
      ansible.builtin.debug:
        msg: "{{ hits.matches }}"

再度実行してみます

f:id:usage_automate:20210414221215p:plain

想定通りに動きました。

genie dqのcustom_filter自作してみた

ansible.utils.cli_parseで出力した辞書から任意のkeyだったりvalueだったりを取り出すのにgenieのdqを使ってみたくなりました
実装するには自分でcustom_filter作るしかなかったので試してみました

Dqの説明は以下

公式ドキュメント
https://pubhub.devnetcloud.com/media/genie-docs/docs/userguide/utils/index.html

環境

ansible 2.10.7
genie 21.2.3

参考にしたGithub

こちらのfilter_pluginを参考に作成しました github.com

filter_dq.pyというスクリプトを作成しました

from __future__ import absolute_import, division, print_function
__metaclass__ = type

from ansible.module_utils.six import PY3
from ansible.errors import AnsibleError, AnsibleFilterError

try:
    from genie.utils import Dq
    HAS_GENIE = True
except ImportError:
    HAS_GENIE = False

class FilterModule(object):
    def __init__(self):
        if not PY3:
            raise AnsibleFilterError("Genie requires Python 3")

        if not HAS_GENIE:
            raise AnsibleFilterError("Genie not found. Run 'pip install genie'")

    def genie_dq_contains(self, output,value):
        try:
          parsed_output = Dq(output).contains(value).reconstruct()
        except Exception as e:
            raise AnsibleFilterError("DQ Error: {0}".format(e))

        if parsed_output:
            return parsed_output
        else:
            return None

~~ snip~~

    def filters(self):
        return {
            'genie_dq_contains': self.genie_dq_contains,
            'genie_dq_get_values': self.genie_dq_get_values,
            'genie_dq_contains_key_value': self.genie_dq_contains_key_value,
            'genie_dq_value_operator': self.genie_dq_value_operator,
        }

genie_dq_contrainsのメソッドにfilterとして呼び出された際の処理を記載しています、 filtersメソッドはfilter呼び出し時のkeywordを定義しています。

Playbookでこのgenie_dq_contrainsを呼び出すときは以下のようになります

"{{ res_show | genie_dq_contrains('route') }}"

res_showの変数が一番目の変数(output)として入り、valueは2番目の変数(value)としてfilterに渡されます
引き渡された変数はGenieのDqで特定のデータを抽出します

同じ要領で以下4つのメソッドを作成してみました 1. genie_dq_contains 2. genie_dq_get_values 3. genie_dq_contains_key_value 4. genie_dq_value_operator

それでは実際に動かしてみます

下準備

custom_filterを使うには ansible.cfgにfilterパスを追加する必要があります filter_pluginにcustom_filterが配置されているパスを定義します。

[defaults]
filter_plugins = filter_plugins

これで下準備は完了です

使い方

動作確認のため、以下のようなPlaybookを書きました
処理の内容としては、ansible.utils.cli_parseでshow ip routeを送信しparser pyatsでパースをします。
この時の戻り値をres_showに格納し、debugモジュールで表示する際にcustom_filterにかけていきます。

---
- hosts: ios01
  gather_facts: false

  tasks:
    - name: コマンド送信
      ansible.utils.cli_parse:
        command: show ip route
        parser:
            name: ansible.netcommon.pyats
      register: res_show

    - name: routeの一覧
      ansible.builtin.debug: 
        msg: "{{ res_show | genie_dq_get_values(value) }}"
      vars: 
        value: 'routes'
  
    - name: 対象のrouteの取得
      ansible.builtin.debug:
        msg: "{{ res_show | genie_dq_contains_key_value(key,value) }}"
      vars: 
        key: 'routes'
        value: "0.0.0.0/0"

    - name: 想定のNexthopのrouteをListにしてから対象のrouteを表示
      ansible.builtin.debug:
        msg: "{{ res_show | genie_dq_contains_key_value(key,value) | genie_dq_contains(route) }}"
      vars: 
        key: 'next_hop'
        value: "10.10.20.254"
        route: "0.0.0.0/0"

    - name: routeのpreference値が01のrouteのみ表示
      ansible.builtin.debug: 
        msg: "{{ res_show | genie_dq_value_operator(arg1,arg2,arg3) }}"
      vars: 
        arg1: 'route_preference'
        arg2: '=='
        arg3: '1'

実行対象はいつも通り、Devnetのalwaysonのリソースを使わせていただいています。

実行

さっそく実行してみます。 ログが長くなってしまったので、分割して結果について紹介していきます。

custom_filter_genie_dq# ansible-playbook -i inventory/hosts.ini dq_sample.yml 

PLAY [ios01] *************************************************************************************************************************************************

TASK [コマンド送信] ************************************************************************************************************************************************
ok: [ios01]

TASK [routeの一覧] **********************************************************************************************************************************************
ok: [ios01] => {
    "msg": [
        "0.0.0.0/0",
        "10.10.20.0/24",
        "10.10.20.48/32",
        "10.255.255.0/24",
        "10.255.255.1/32"
    ]
}

上記はgenie_dq_get_valuesで'route'のvalueをlistとして格納しています。
単純にルートの数などが知りたいときに使えるかなぁ

TASK [対象のrouteの取得] *******************************************************************************************************************************************
ok: [ios01] => {
    "msg": {
        "parsed": {
            "vrf": {
                "default": {
                    "address_family": {
                        "ipv4": {
                            "routes": {
                                "0.0.0.0/0": {
                                    "active": true,
                                    "metric": 0,
                                    "next_hop": {
                                        "next_hop_list": {
                                            "1": {
                                                "index": 1,
                                                "next_hop": "10.10.20.254",
                                                "outgoing_interface": "GigabitEthernet1"
                                            }
                                        }
                                    },
                                    "route": "0.0.0.0/0",
                                    "route_preference": 1,
                                    "source_protocol": "static",
                                    "source_protocol_codes": "S*"
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

続いてgenie_dq_contains_key_valueでkeyとvalueの組み合わせを指定しデータを抽出します
routeの中の0.0.0.0/0のデータのみ抜き出しています。
もちろん存在しない場合は空っぽで応答されます。

TASK [想定のNexthopのrouteをListにしてから対象のrouteを表示] *****************************************************************************************************************
ok: [ios01] => {
    "msg": {
        "parsed": {
            "vrf": {
                "default": {
                    "address_family": {
                        "ipv4": {
                            "routes": {
                                "0.0.0.0/0": {
                                    "next_hop": {
                                        "next_hop_list": {
                                            "1": {
                                                "next_hop": "10.10.20.254"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

続いてはgenie_dq_contains_key_valueとgenie_dq_containsの合わせ技になります genie_dq_contains_key_valueでnext_hopが10.10.20.254のデータのみを抽出し、その後
genie_dq_containsでrouteが0.0.0.0/0のものを抽出しています。 (| をつないで複数のfilterをつなぎ合わせることも可能ってのを見せたかった例)

TASK [routeのpreference値が01のrouteのみ表示] ************************************************************************************************************************
ok: [ios01] => {
    "msg": {
        "parsed": {
            "vrf": {
                "default": {
                    "address_family": {
                        "ipv4": {
                            "routes": {
                                "0.0.0.0/0": {
                                    "route_preference": 1
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

PLAY RECAP ***************************************************************************************************************************************************
ios01                      : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

最後はgenie_dq_value_operatorです、
route_preferenceが1のデータのみ抽出しています

まとめ

はじめてcustom_filterを作成したが思ったよりも簡単に実装することができました。 パパっとできた割には結構柔軟に使えて便利なfilterになりました

なるべく既存のものを利用し、自分でガリガリ作らないほうがいいってポリシーなんですが
作ってみると楽しくて、これもcustom_filterでできそうだなと考えてしまう頭になってしまいそうです。

ただライセンス周りがいまいちわからず(このブログもダイジョブなんかなとか思ったり) githubで公開できるように勉強中です・・・ (詳しい方いたら教えてください・・・)

ネットワークリソースモジュールで遊んでみる

ネットワークリソースモジュールを有効活用したい

ansible v2.9で追加されたネットワークリソースモジュールですが、いままでろくに使ったことがありませんでした。 少し時間ができたので、いろいろと試してみました。

ネットワークリソースモジュールとは

さまざまなネットワークデバイスの管理方法を簡素化および標準化

しているらしい
特徴としては下記のstateがあります、それぞれの説明は割愛させていただきます。

  1. merged
  2. replaced
  3. overridden
  4. deleted
  5. gathered
  6. rendered
  7. parsed

詳しいことは公式ドキュメントにお任せ docs.ansible.com

個人的にパッと見た時に特徴的だったのはreplacedでした。 今までのios_configでは設定の置き換えができなかった思いますので「これは、始まったか」と心の中でつぶやきました(嘘)

どう使うか

個人的にNW機器の構成管理をする際にPlaybook(もしくは変数ファイル)をあるべき状態に定義すればよい状態を目指しています ネットワークリソースモジュールを使って状態を目指します。

そもそもあるべき状態を定義すればよいってどんな状態なの?

今回はIOSのStaticRouteのAnsibleで設定していきます。

あるべき状態、つまり設定値は変数ファイル(static_route.yml)として host_vars配下に配置してあります

 -- inventory
    |-- group_vars
    |   `-- ios.yml
    |-- host_vars
    |   `-- ios01
    |       `-- static_route.yml
    `-- ios1.ini

中身は以下にようになっています(インデントが若干おかしいのはいったん見逃してください)。

static_route:
-   address_families:
    -   afi: ipv4
        routes:
        -   dest: 0.0.0.0/0
            next_hops:
            -   forward_router_address: 10.10.20.254
                interface: GigabitEthernet1
    -   afi: ipv4
        routes:
        -   dest: 10.17.253.101/32
            next_hops:
            -   forward_router_address: 192.168.253.45
    -   afi: ipv4
        routes:
        -   dest: 8.8.8.8/32
            next_hops:
            -   forward_router_address: 10.10.20.254

この変数ファイルに定義してあるroute=機器に設定されているrouteとしたいわけです。 追加削除もこの変数ファイルをいじるだけでOKにし、Config投入時は差分の箇所のみ変更をかけてもらいたい。 冪等性も担保したい、もし手動で設定してしまってもなんとかしたい。

さっそく考えてみる

今回はCiscoDevNetで常時開放しているIOSXEを利用します。

すでにStaticRouteが何本も入っている状態です。

まず変数ファイルを用意する必要があるんですが、手で書き起こすのはさすがにやりたくない。。

そこでネットワークリソースモジュールの出番です。

gatheredを使う

gatheredはfactsに似ています、機器の設定を取得し決まった形に変換してくれます。

こんなPlaybookを作成し実行してみました。

---
- hosts: ios01
  gather_facts: False

  tasks:
    - name: gathered
      cisco.ios.ios_static_routes:
        state: gathered
      register: gather_result

    - name: Write the Static Route configuration to a file
      copy:
        content: "{{ {'static_route': gather_result['gathered'] } | to_nice_yaml }}"
        dest: "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}/static_route.yml"
      delegate_to: localhost

staticrouteの設定を取得し、parseし格納されます。 f:id:usage_automate:20210331194030p:plain

これで、実機から設定を変数ファイルに書き起こすことができました。(上に張っていたstatic_route.ymlです) (nice_to_yamlだと-のあとに余計にスペースはいったりちょっと変なんですよねぇ)

変数ファイルに追加や削除してみる

さて、先ほど出力したファイルをいじってみましょう。

設定追加

static_route.ymlに8.8.8.9/32のルートを追加してみます。 8.8.8.8/8を参考にコピペで作ります。

static_route:
-   address_families:
    -   afi: ipv4
        routes:
        -   dest: 0.0.0.0/0
            next_hops:
            -   forward_router_address: 10.10.20.254
                interface: GigabitEthernet1
    -   afi: ipv4
        routes:
        -   dest: 10.17.253.101/32
            next_hops:
            -   forward_router_address: 192.168.253.45
    -   afi: ipv4
        routes:
        -   dest: 8.8.8.8/32
            next_hops:
            -   forward_router_address: 10.10.20.254
    -   afi: ipv4
        routes:
        -   dest: 8.8.8.9/32
            next_hops:
            -   forward_router_address: 10.10.20.254

完成したので、設定だ!!

と、そのまえに

想定通りか確認してみる

投入するルートが想定通りか、どんなconfigを投入するのか?どんな結果になるのか?を確認したくなったので先に確認しましょう。

確認用のPlaybookを作成しました。 こちらはgatheredとrenderedの合わせ技になります。

renderedは設定時のconfigを出力してくれるstateになり、実機にログインしないでも利用可能です(Localで使える)

gatheredで取得した設定からrenderedした結果とstatic_route.ymlからrenderedした結果をdiffしてみます

diffにはfact_diffを利用しました。

---
- hosts: ios01
  gather_facts: False

  tasks:
    - name: gathered
      cisco.ios.ios_static_routes:
        state: gathered
      register: gather_result

    - name: current config rendered
      cisco.ios.ios_static_routes:
        config: "{{ gather_result['gathered']  }}"
        state: rendered
      register: current_config

    - name: asumed config rendered 
      cisco.ios.ios_static_routes:
        config: "{{ static_route }}"
        state: rendered
      register: assumed_config

    - name: fact_diff
      ansible.utils.fact_diff:
        before: "{{ current_config.rendered }}"
        after: "{{ assumed_config.rendered }}"

実行してみましょう

f:id:usage_automate:20210331195037p:plain

追加予定routeのip route 8.8.8.9 255.255.255.255 10.10.20.254が+で見えました。 これは見やすい(よね?)

今度こそ実行

それでは設定変更を実施しましょう

用意したPlaybookがこちら

---
- hosts: ios01
  gather_facts: False

  tasks:
    - name: replaced
      cisco.ios.ios_static_routes:
        config: "{{ static_route }}"
        state: replaced

ポイントはstate: replacedですね

さて実行、どーーーーん! f:id:usage_automate:20210331195918p:plain

設定後の確認をしてみる

設定後の確認をしましょう、今回はconfigが想定通り設定されたか?の観点に絞って確認をします。

こちらは先ほど作成したPlaybookを再度実施することで簡単にできます。 f:id:usage_automate:20210331200049p:plain

変更なし=gatheredで取得した設定からrenderedした結果とstatic_route.ymlからrenderedした結果に差分なし

ということで想定通りに設定ができました。Ansible最高

おまけで削除

さて、DevnetのIOSXEにゴミをいれてしまったので最後は削除といきましょう。

まずは変数ファイルからいらないrouteを削除します

static_route:
-   address_families:
    -   afi: ipv4
        routes:
        -   dest: 0.0.0.0/0
            next_hops:
            -   forward_router_address: 10.10.20.254
                interface: GigabitEthernet1
    -   afi: ipv4
        routes:
        -   dest: 10.17.253.101/32
            next_hops:
            -   forward_router_address: 192.168.253.45

8.8.8.8/32と8.8.8.9/32のルートですね、こいつは私が足したので削除します。

追加時と同じように設定前の確認をします。

f:id:usage_automate:20210331200526p:plain

削除対象は赤色で出てきます(わかりやすい!)

ではreplaceを実行 f:id:usage_automate:20210331200802p:plain

え?なんでOK?

replacedの動きがよくわからない。。

overriddenが適切なのかも、、(自分の検証機じゃないのでoverriddenはスキップさせてください(´;ω;`))

ソースコードを読む気になれずでここでタイムアップ

まとめ

実際に動かすことが重要だと再認識しました。。。。

最後の削除で想定通り動かず不完全燃焼ですが、ネットワークリソースモジュールについて少し理解が深まってよかったです。

gatheredやrenderedなどの機能も便利に使えそうで妄想が膨らみますね。 今回想定と違ったoverridden/replaceの動きについてはリトライして勉強しようと思います。 (そもそもreplaceって文字だけでこんな挙動かなって勝手に想定していました、よくないですね)

変数ファイルをいじくるだけで設定を変更できるならGitとの相性もよりよくなるかも?とか、CIで投入予定のconfigまで出しておいて承認待ちで止めとくとかすればみんなの恐怖減るかな?とかいろいろと考えたら楽しくなりました。

CiscoACIのvzAny設定をAnsibleで一気に作る

vzAnyの設定がしたい、したことないからしたい

そんなことを考えながら日々生き抜いていました。

なのでやりました、ホントはrole化してもっと自由に組み合わせられるように作るべきなんですが、めんどくさかったです。(意志弱め)

Playbook作成前に、手動で一通りの作業を実施し、順序や対象設定の確認をしています、いきなりPlaybookをしないで作業のイメージを掴むことは非常に重要だと思っています

AccessPolicyの設定は無視で、Tenantの設定のみ対象にしています。

どんな場面で使うか?それはSandBox環境で俺俺勉強環境を作るくらいでしか思い浮かびません(正直者)

具体的な設定手順は以下の通りです

  1. Tenant作成
  2. VRF作成
  3. Contract作成
  4. Filter作成
  5. Filete Entry作成(permit anyで作成)
  6. ContractとFilterを紐付け
  7. vzAnyのProviderContract設定(aci_rest)
  8. vzAnyのConsumedContract設定(aci_rest)

7,8はモジュールがないため、aci_restで実装しました。

aci_restで利用するパスやコンテンツはShow ACI Inspectorの機能を利用しています f:id:usage_automate:20210121225829p:plain

ACI Inspectorを起動した状態でGUI作業を実施することで対応するURLやCURLDやコンテンツなどの情報が確認できます f:id:usage_automate:20210121230149p:plain

どうやら/api/node/mo/uni/tn-テナント名/ctx-VRF名/any.jsonのURLに対して、コンテンツvzRsAnyToCons/vzRsAnyToProvでContractを指定して送ることで設定ができることが確認できました。

出来上がったPlaybookがこちらです。

---
- hosts: APIC
  gather_facts: false
  connection: local
  vars_files: vars/sample.yml
  vars:
    aci_auth:
      - &aci_auth
        host: "{{ inventory_hostname }}"
        username: "{{ username }}"
        password: "{{ password }}"
        validate_certs: false

  tasks:
    - name: add a new Tenant
      aci_tenant:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        state: present

    - name: add a new VRF
      aci_vrf:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        vrf: "{{ vrf.name }}"
        policy_control_preference: enforced
        policy_control_direction: ingress
        state: present

    - name: add vzAny Contract
      aci_contract:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        scope: context
        dscp: unspecified
        priority: unspecified
        contract: "{{ vzAny_contract }}"
        state: present

    - name: add all permit filter 
      aci_filter:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        filter: all_permit
        state: present

    - name: add all permit filter entry
      aci_filter_entry:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        filter: all_permit
        entry: all_permit
        ether_type: unspecified
        ip_protocol: unspecified
        dst_port: unspecified
        stateful: no
        state: present

    - name: add subject to vzAny Contract 
      aci_contract_subject:
        <<: *aci_auth
        tenant: "{{ tenant.name }}"
        subject: all_permit
        contract: "{{ vzAny_contract }}"
        state: present

    - name: add vzAny Contracts(Provided)
      aci_rest:
        <<: *aci_auth
        path: /api/node/mo/uni/tn-{{ tenant.name }}/ctx-{{ vrf.name }}/any.json
        method: post
        content:
          vzRsAnyToProv:
            attributes:
              tnVzBrCPName: "{{ vzAny_contract }}"

    - name: add vzAny Contracts(Consumed)
      aci_rest:
        <<: *aci_auth
        path: /api/node/mo/uni/tn-{{ tenant.name }}/ctx-{{ vrf.name }}/any.json
        method: post
        content:
          vzRsAnyToCons:
            attributes:
              tnVzBrCPName: "{{ vzAny_contract }}"

テナント単位で設定ファイルを用意し、このファイルを見れば大体構成わかるよね?という状態を目指しています。なのでこのような構成でAnsibleを作ってブログに載せることも多いかと思います。 sample.yml(今回はAP/BD/EPGの設定部分は利用していません)

tenant:
  name: sample_tenant
ap:
  name: sample_ap
epgs:
  - { name: sample_epg, bd: sample_bd, vlan: 100 }
vrf: 
  name: sample_vrf
bds: 
  - { name: sample_bd, ip: 10.0.0.1, mask: 28 }

vzAny_contract: vzAny_contract

一応実行ログ

PLAY [APIC] ************************************************************************************************************************************************************

TASK [add a new Tenant] ************************************************************************************************************************************************
[WARNING]: Platform darwin on host sandboxapicdc.cisco.com is using the discovered Python interpreter at /usr/bin/python, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/reference_appendices/interpreter_discovery.html for more information.

changed: [sandboxapicdc.cisco.com]

TASK [add a new VRF] ***************************************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add vzAny Contract] **********************************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add all permit filter] *******************************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add all permit filter entry] *************************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add subject to vzAny Contract] ***********************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add vzAny Contracts(Provided)] ***********************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

TASK [add vzAny Contracts(Consumed)] ***********************************************************************************************************************************
changed: [sandboxapicdc.cisco.com]

PLAY RECAP *************************************************************************************************************************************************************
sandboxapicdc.cisco.com    : ok=8    changed=8    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

実行も正常に完了し、2回流すと全てOKのステータスで冪等性も担保されています。

まぁ、便利!作ってよかったなぁ!(大声)