うさラボ

お勉強と備忘録

[Ansible] expectモジュールでNW機器へのアクセスを試みる

Ansible Advent Calendar 2023の17日目の記事です。

qiita.com

概要

Ansibleを使いたいけどTelnetしか対応していないんだよね~
踏み台アクセスってできる?

といったAnsible使う上では中々の無茶ぶりを要求されることがあります(あります)

Ansibleは基本的にはSSHが前提となっているため、難しいことをしっかりと伝えましょう。しかし、どうしてもやらないといけない場合もあります。そんな時どうするのか?僕はexpectモジュールを使いました。

紹介します

expectモジュール

コマンドを実行して、表示されたプロンプトに対応する応答を返すモジュールになります。利用にはpexpectライブラリが必要になるので事前にインストールしておきましょう

pip install pexpect

モジュールの開始時に実行するコマンドのcommandパラメータと、そのコマンドに対するプロンプトと応答のresponsesパラメータを定義します。
下記の場合、/path/to/custom/commandを実行後Questionのプロンプトが表示されるので、表示されるごとにresponse1,response2,respose3の順番で応答します。 responsesのキーがプロンプト、バリューが応答の関係になります。応答はリストで定義すると上から順番に実行していきリストの要素をすべて応答しきってもプロンプトが表示される場合とエラーになります。文字列で定義するとプロンプトに対して何度でも応答ができます。

- name: Generic question with multiple different responses
  ansible.builtin.expect:
    command: /path/to/custom/command
    responses:
      Question:
        - response1
        - response2
        - response3

NW機器の場合はログイン時に表示されるホスト名>ホスト名#をキーにします。踏み台アクセスしたいよって場合、踏み台用のプロンプトを作ればいいってことです。

リダイレクトやパイプを使いたい場合は、ドキュメントにあるように/bin/bash -c "/path/to/something | grep else"こんな感じで書いてあげる必要があります

このモジュールを使い、TelnetSSHコマンドを実行してNW機器だろうが何だろうが接続してやろうといった魂胆です

docs.ansible.com

なぜTelnetモジュールを使わないのか?

  1. 機器側の出力が増えるととんでもなく遅くなる事象がありました。
    キャプチャしたところTCP Zero Windowsが表示されまくり処理がいつまでたっても終わらなかったことがありました
  2. login_promptやpassword_promptのパラメータにやや癖がありやめました
  3. promptsとcommandがexpectのほうが柔軟でした

準備

さてNW機器に接続するぜと息巻いたもののNW機器を準備できませんでした。
なのでNW機器のプロンプトを再現する用のスクリプトを動かしてそれっぽく出てくる応答を取得できるか確認することにします。

responses ={"show_ip_route" : """Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
L        10.1.1.1/32 is directly connected, GigabitEthernet0/0

      172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
S        172.16.2.0/24 [1/0] via 10.1.1.2"""}

import re
if __name__ == '__main__':
    hostname = 'hogehoge'
    while True:
        user_prompt = input(hostname + '> ')
        if user_prompt == 'exit':
            break
        elif user_prompt == 'enable':
            while True:
                admin_prompt = input(hostname + '# ')
                if admin_prompt == 'exit':
                    break
                elif re.match(r'^show', admin_prompt):
                    response_key = admin_prompt.replace(' ','_')
                    if response_key in responses.keys():
                        print(responses[response_key])
                    else:
                        print('すまん、未対応や')
                elif admin_prompt == 'help':
                    [print(i.replace('_',' ')) for i in responses.keys()]
        else:
            print('すまん、未対応や')

このスクリプトで,NW機器(Cisco)プロンプト待ちを再現しています
おまけでshow ip routeを打つとそれっぽい応答を返すようにしました

(2023_Advend) [usaen@eda work]$ python rt_prompt.py 
hogehoge> enable
hogehoge# show ip route
Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
L        10.1.1.1/32 is directly connected, GigabitEthernet0/0

      172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
S        172.16.2.0/24 [1/0] via 10.1.1.2
hogehoge# exit
hogehoge> exit

Playbook

実際に動かすPlaybookを書きます
set_factで生成した辞書をexpectモジュールに渡そうと思いましたが、>や#で終わる場合はエラーになるようでうまく設定できませんでした そのため、community.generalコレクションのdictフィルタを利用しました (実はこのフィルタ、この記事を書き始めてから知りました、頑張ってloopとcombineで辞書作ろうとしてました)

docs.ansible.com

---
- name: 2023 Advent Carender
  hosts: localhost
  gather_facts: false
  tasks:
    - name: これはうまくいかない
      ansible.builtin.set_fact:
        "{{ hostname }}>": ["enable", "exit"]
        "{{ hostname }}#": ["ter len 0", "exit"]
      vars:
        hostname: hogehoge
      ignore_errors: true

    - name: Expectで使う用の変数
      ansible.builtin.set_fact:
        prompt2: "{{ prompt_keys | zip(prompt_values) | community.general.dict }}"
      vars:
        prompt_keys:
          [
            "{{ hostname }}>", 
            "{{ hostname }}#",
          ]
        prompt_values:
          [
              ["enable", "exit"], 
              ["ter len 0", "show ip route", "exit"],
          ]
        hostname: hogehoge

    - name: expect
      ansible.builtin.expect:
        command: python rt_prompt.py
        responses: "{{ prompt2 }}"
        echo: true
      register: res_expect

    - name: 実行結果
      debug:
        msg: "{{ res_expect.stdout }}"

expectモジュールで設定しているechoのパラメータは、応答した内容を結果に表示するか?の設定で、上記例だと、enableやshow ip routeなど応答した情報も結果に表示させれます。

telnet、もしくはsshコマンドをcommandで設定することでAnsibleサーバからSSHTelnetをすることができます

  - name: expect
      ansible.builtin.expect:
        command: telnet {{ ansible_host }}
        responses: "{{ prompt2 }}"
        echo: true
      register: res_expect

この場合は、当然responsesにはUserやPasswordなど、Telnet/SSHコマンド実行時のプロンプトも追加する必要があります。

実行

stdoutを表示するので、デフォルトのコールバックプラグインでは見づらいため
ANSIBLE_STDOUT_CALLBACKをyamlに変更しつつ実行します

$ ANSIBLE_STDOUT_CALLBACK=yaml ansible-playbook expect_sample.yml -i hosts.yml 

PLAY [2023 Advent Carender] **************************************************************

TASK [これはうまくいかない] **************************************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: NoneType: None
fatal: [localhost]: FAILED! => changed=false 
  msg: The variable name 'hogehoge>' is not valid. Variables must start with a letter or underscore character, and contain only letters, numbers and underscores.
...ignoring

TASK [Expectで使う用の変数] **************************************************************
ok: [localhost]

TASK [expect] ****************************************************************************
changed: [localhost]

TASK [実行結果] **************************************************************************
ok: [localhost] => 
  msg: |-
    hogehoge> enable
    hogehoge# ter len 0
    hogehoge# show ip route
    Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
           D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
           N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
           E1 - OSPF external type 1, E2 - OSPF external type 2
           i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
           ia - IS-IS inter area, * - candidate default, U - per-user static route
           o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
           a - application route
           + - replicated route, % - next hop override
  
    Gateway of last resort is not set
  
          10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
    C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
    L        10.1.1.1/32 is directly connected, GigabitEthernet0/0
  
          172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
    C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
    L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
    S        172.16.2.0/24 [1/0] via 10.1.1.2
    hogehoge# exit
    hogehoge> exit

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

プロンプトと応答をコツコツ定義していくのでTeratermマクロみたいっちゃみたいです。

ちなみ、NW機器はバージョンやOSによって改行コードが違ったり、プロンプトの表示が微妙に変わったりするので、expectでなんでもしようと思うと思わぬところではまったりします。

ベンダーモジュールを使えるように環境を整備する方に力をいれましょう。

[Ansible] running-configをいい感じに比較をしたい(NetworkConfigを使う)

Ansible Advent Calendar 2023 10日目の記事です

qiita.com

概要

Ansibleでネットワーク機器に設定をするとき、設定後に事前に用意した想定コンフィグと比較したいときがあります。

iosのshow runは、スペースの個数で階層を表していて、単純に差分を出すだけではどこの差分か?がわからなかったりします。

例えば、こんなdiff結果

差分があるのはわかったけどどの部分かわかりにくい、、(行番号とか出るけど、まぁ、、)

今回はansible.netcommonのmodule_utilsにあるNetworkConfigクラスというものを使って、いい感じに差分を出す仕組みを考えてみました

NetworkConfigとは

netcommon/plugins/module_utils/network/common/config.pyにあるNetworkConfigクラスを使って実装します。 NetworkConfigはxxx_config系のモジュールで使われるもので、Ciscoのようなスペースで階層(親子関係)を示すコンフィグをパースしたり、Diffしたりする機能があります。

xxx_config系はこのクラスを活用して、送信前後の差分などを判定しているようです。 (本ブログではここには触れないです)
つまり、この機能をうまく使えば簡単にrunning-configの比較ができる?と考えました

github.com

ざっくり説明すると
NetworkConfigにコンテンツ(文字列)を読み込ませると、1行ずつConfigLineクラスを生成されて設定の親子関係設定をしているようです。

手順

フィルタ

NetworkConfigクラスを使って2つの機能を考えてみました

1つは、NetworkConfigのparseメソッドを使ってConfigLineクラス呼び出し、行ごとの親子関係含めた形でリストを作成します
こんなコンフィグが

!
policy-map TRAFFIC_CONTROL
 class HTTP
  police 1000000 10000 exceed-action drop
!

こんなリストになります

- policy-map TRAFFIC_CONTROL
- policy-map TRAFFIC_CONTROL class HTTP
- policy-map TRAFFIC_CONTROL class HTTP police 1000000 10000 exceed-action drop

XRのshow run formatの出力みたいなものです
この形式でコンフィグがリストになれば、差分が出しやすそうと思って作りました。(こちらは表示するまでにしてます)

2つ目は、2つのコンフィグの差分を出す機能です NetworkConfigクラスにはdifferenceメソッドがあり、matchの条件に沿った差分を出すことができます

igrnore_linesは文字通り無視する行になります、正規表現のリストを渡すことができます。

# NOTE: netcommonのNetworkConfigをインポート
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import (
    NetworkConfig,
)


def running_to_list(cfg, ignore_lines=None):
    ncfg = NetworkConfig(contents=cfg, ignore_lines=ignore_lines)
    return [i.line for i in ncfg.items]


def diff_config(cfg1, cfg2, match="line", ignore_lines=None):
    res = list()
    ncfg1 = NetworkConfig(contents=cfg1, ignore_lines=ignore_lines)
    ncfg2 = NetworkConfig(contents=cfg2, ignore_lines=ignore_lines)
    diff_lines = ncfg1.difference(other=ncfg2, match=match)
    for i in diff_lines:
        res.append(str(i))
    return "\n".join(res)


class FilterModule(object):
    def filters(self):
        return {
            "running_to_list": running_to_list,
            "diff_config": diff_config,
        }

用意したコンフィグ

適当なコンフィグをChatGPTに作ってもらいました、
設定が3階層(親・子・孫)くらいあればいいなぁと思って出しました

↑のコンフィグをベースに、一部のみ設定を変えたコンフィグを準備しました
ACLを一行追加したのでとTRAFFIC_CONTROLのexceed-actionをtrasmitに変更しました

2つのコンフィグに変化をつけたかっただけなので、値は適当です

before.cfg

Building configuration...

Current configuration:
!
version 15.1
service timestamps debug datetime msec
service timestamps log datetime msec
!
access-list 101 permit tcp any host 192.168.1.1 eq 80
access-list 101 deny ip any any
!
class-map match-all HTTP
 match access-group 101
!
policy-map TRAFFIC_CONTROL
 class HTTP
  police 1000000 10000 exceed-action drop
!
interface GigabitEthernet0/0
 service-policy input TRAFFIC_CONTROL
!
end

after.cfg

Building configuration...

Current configuration:
!
version 15.1
service timestamps debug datetime msec
service timestamps log datetime msec
!
access-list 101 permit tcp any host 192.168.1.1 eq 80
access-list 101 permit tcp any host 192.168.1.2 eq 80
access-list 101 deny ip any any
!
class-map match-all HTTP
 match access-group 101
!
policy-map TRAFFIC_CONTROL
 class HTTP
  police 1000000 10000 exceed-action transmit
!
interface GigabitEthernet0/0
 service-policy input TRAFFIC_CONTROL
!
end

Playbook

Playbookではlookupでコンフィグを読み込み、 読み込んだデータに対してフィルタをかけます

---
- name: 2023 Advent Carender
  hosts: localhost
  gather_facts: false
  tasks:
    - name: set data
      ansible.builtin.set_fact:
        conf1: "{{ lookup('file','before.cfg') }}"
        conf2: "{{ lookup('file','after.cfg') }}"
        match: "line"
        ignore_lines:
          - "end"
          - "Current"
          - "version"

    - name: 😊
      ansible.builtin.debug:
        msg: 
          - "{{ conf1 | running_to_list(ignore_lines) }}"
          - "{{ conf2 | running_to_list(ignore_lines) }}"

    - name: 👌
      ansible.builtin.debug:
        msg: 
          - "事前\n{{ conf1 | diff_config(conf2, match, ignore_lines) }}"
          - "事後\n{{ conf2 | diff_config(conf1, match, ignore_lines) }}"

実行結果

差分のフィルタは文字列で結果を返すので、出力を見やすくするためにcallbackをyamlに変更して実行します
親のコンフィグも結果に帰ってくるので、どの親に所属するコンフィグで差分があるか?までがわかりますね

$ ANSIBLE_STDOUT_CALLBACK=yaml ansible-playbook config.yml 
[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 [2023 Advent Carender] **********************************************************************************************

TASK [set data] **********************************************************************************************************
ok: [localhost]

TASK [😊] ****************************************************************************************************************
ok: [localhost] => 
  msg:
  - - service timestamps debug datetime msec
    - service timestamps log datetime msec
    - access-list 101 permit tcp any host 192.168.1.1 eq 80
    - access-list 101 deny ip any any
    - class-map match-all HTTP
    - class-map match-all HTTP match access-group 101
    - policy-map TRAFFIC_CONTROL
    - policy-map TRAFFIC_CONTROL class HTTP
    - policy-map TRAFFIC_CONTROL class HTTP police 1000000 10000 exceed-action drop
    - interface GigabitEthernet0/0
    - interface GigabitEthernet0/0 service-policy input TRAFFIC_CONTROL

TASK [👌] ****************************************************************************************************************
ok: [localhost] => 
  msg:
  - |-
    事前
    policy-map TRAFFIC_CONTROL
     class HTTP
      police 1000000 10000 exceed-action drop
  - |-
    事後
    access-list 101 permit tcp any host 192.168.1.2 eq 80
    policy-map TRAFFIC_CONTROL
     class HTTP
      police 1000000 10000 exceed-action transmit

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

手動でDiffした結果とも同じく変更がある行のみがフィルタの結果として帰ってきています

試しにmatch変数を変更してみます。 デフォルトで選択するlineは行単位,structは行の位置,exactは厳密なチェックとなります
厳密なチェックというのは、出力全体の差分が一切ない状態です

structを試してみます、コンフィグの表示位置を無理くり変更させます interfaceの設定はpolicy-mapの設定より後に表示されるのが通常ですが、access-listの前に配置してみました

match: lineのままだと、この位置の違いは検出できませんがstrictにして実行すると 位置がずれている行も差分として検出されます

TASK [👌] ******************************************************************************************
ok: [localhost] => 
  msg:
  - |-
    事前
    interface GigabitEthernet0/0
     service-policy input TRAFFIC_CONTROL
    access-list 101 permit tcp any host 192.168.1.1 eq 80
    access-list 101 deny ip any any
    class-map match-all HTTP
     match access-group 101
    policy-map TRAFFIC_CONTROL
     class HTTP
      police 1000000 10000 exceed-action drop
  - |-
    事後
    access-list 101 permit tcp any host 192.168.1.1 eq 80
    access-list 101 permit tcp any host 192.168.1.2 eq 80
    access-list 101 deny ip any any
    class-map match-all HTTP
     match access-group 101
    policy-map TRAFFIC_CONTROL
     class HTTP
      police 1000000 10000 exceed-action transmit
    interface GigabitEthernet0/0
     service-policy input TRAFFIC_CONTROL

このフィルタを活用すれば、
事後確認用のコンフィグを手元で用意したときにも実機の出力と、どれほど差分があるか/ないかを確認することができそうです。

所属するグループ内に失敗したホストがあれば処理スキップしたい

Ansible Advent Calendar 2023 9日目の記事です。

qiita.com

やりたいこと

拠点に所属する機器に対して作業をするとき同拠点内の機器でエラーがあったとき ほかの機器の後続スキップさせたいので考えてみました。

機器1と機器8で何かしらの処理が失敗するとします。
この場合、拠点1の機器2と機器3,拠点3の機器7と機器9も後続処理をスキップさせたいといったものです。

拠点1
  機器1 × ← 失敗
  機器2   ← 失敗してないけどスキップ
  機器3   ← 失敗してないけどスキップ
拠点2
  機器3
  機器5
  機器6
拠点3
  機器7   ← 失敗してないけどスキップ
  機器8 × ← 失敗
  機器9   ← 失敗してないけどスキップ

手順

inventory

インベントリは以下のように作成しました
グループA,B,Cにそれぞれ3ホストづつ所属させています。

グループ変数に定義したgroup_idは、所属するグループ(拠点)を識別させるものです。 グループ名と一致させています(Playbookで動的に取得させようともしましたが、ホストが複数のグループに所属していたりするのを考えるのが面倒だったのでこの形にしちゃいました)

---
all:
  children:
    targets:
      children:
        groupA:
          hosts:
            host1:
              ansible_connection: local
            host2:
              ansible_connection: local
            host3:
              ansible_connection: local
          vars:
            group_id: groupA
        groupB:
          hosts:
            host4:
              ansible_connection: local
            host5:
              ansible_connection: local
            host6:
              ansible_connection: local
          vars:
            group_id: groupB
        groupC:
          hosts:
            host7:
              ansible_connection: local
            host8:
              ansible_connection: local
            host9:
              ansible_connection: local
          vars:
            group_id: groupC

Playbook

block/resucueとgroup_byを使って処理が失敗したホストをfailedグループに含め、
マジック変数のgroupsとグループ変数に定義したgroup_idを使って自分が所属するグループのホスト一覧を取得し、failedグループに含まれているホストが含まれてない場合のみ処理させます。
intersectフィルタはリストの共通する要素を抽出するもので、failedグループと共通する要素があればグループ内に失敗していたホストがあるといった条件にしました。

最後にfailedグループで処理することで失敗したホストを対象のなんやかんやできます resuceが動くとタスクの失敗ステータスが「取り消され」、成功したかのように続行していきます。

---
- name: グループ内の1hostが失敗していたら、ほかの処理も止めたい
  hosts: targets
  gather_facts: false
  connection: local

  tasks:
    - name: block
      block:
        - name: なにかしらの失敗
          fail:
            msg: "Fail"
          when: "inventory_hostname == 'host1' or inventory_hostname == 'host8'" 
      rescue:
        - name: 失敗したホストfailedグループに追加
          group_by:
            key: failed

    - name: block
      when: ( groups[group_id] | intersect(groups['failed']) | length ) == 0
      block:
        - name: 失敗無いグループのホストのみ処理
          debug:
            msg: "Errorないよ"
      rescue:
        - name: 失敗したホストfailedグループに追加
          group_by:
            key: failed

- name: 失敗したホストの所属するグループを表示
  hosts: failed
  gather_facts: false
  connection: local

  tasks:
    - name: dev
      debug:
        msg: "{{ group_id }}"
    - name: fail
      fail:
        msg: "Error"

実行結果

実行結果はこちらです。 rescueに引っかかるhost1とhost8は、後続の「失敗無いグループのホストのみ処理」でも実行対象になっていることから失敗が取り消されていることも確認できます

想定通り、host1の所属するgroupAとhost8が所属するgroupCの別ホストは処理をスキップさせ グループ内に失敗のないgroupBのみ処理させることができました

$ ansible-playbook group_in_error.yml -i hosts.yaml 

PLAY [グループ内の1hostが失敗していたら、ほかの処理も止めたい] ******************************************************

TASK [なにかしらの失敗] *********************************************************************************************
fatal: [host1]: FAILED! => {"changed": false, "msg": "Fail"}
skipping: [host2]
skipping: [host3]
skipping: [host4]
skipping: [host5]
skipping: [host6]
skipping: [host7]
fatal: [host8]: FAILED! => {"changed": false, "msg": "Fail"}
skipping: [host9]

TASK [失敗したホストfailedグループに追加] ***************************************************************************
changed: [host1]
changed: [host8]

TASK [失敗無いグループのホストのみ処理] *****************************************************************************
skipping: [host1]
skipping: [host2]
skipping: [host3]
ok: [host4] => {
    "msg": "Errorないよ"
}
ok: [host5] => {
    "msg": "Errorないよ"
}
ok: [host6] => {
    "msg": "Errorないよ"
}
skipping: [host7]
skipping: [host8]
skipping: [host9]

PLAY [失敗したホストの所属するグループを表示] ***********************************************************************

TASK [dev] **********************************************************************************************************
ok: [host1] => {
    "msg": "groupA"
}
ok: [host8] => {
    "msg": "groupC"
}

TASK [fail] *********************************************************************************************************
fatal: [host1]: FAILED! => {"changed": false, "msg": "Error"}
fatal: [host8]: FAILED! => {"changed": false, "msg": "Error"}

PLAY RECAP **********************************************************************************************************
host1                      : ok=2    changed=1    unreachable=0    failed=1    skipped=1    rescued=1    ignored=0   
host2                      : ok=0    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   
host3                      : ok=0    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   
host4                      : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
host5                      : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
host6                      : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
host7                      : ok=0    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   
host8                      : ok=2    changed=1    unreachable=0    failed=1    skipped=1    rescued=1    ignored=0   
host9                      : ok=0    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0

わーい

別解も知りたい

AWXで作成したテキストファイルをZipに固めて手元に持ってきたい

Ansible Advent Calendar 2023の7日目の記事です。

qiita.com

やりたいこと

AWXで何か処理を実行した際、ログなどをテキストとして保存したいときがあります。(あります)
基本は、debugモジュールなどで実行結果に表示させればログは残せます、機器がたくさんあったり出力が長いと見にくかったりします。

もしテキストファイルなどを作成した場合も、デフォルトでは処理を実行したPodはJobTemplateが完了したタイミングで終了されるためテキストを残す場合にはAWXの細かい設定やPlaybook側で調整が必要だったりします

今回はPlaybook実行時に生成したテキストファイルをZIPファイルに圧縮し、
Base64でバイナリデータにしてJobTemplateのArtifactsに登録する、さらに手元の環境まで持ってくる方法を紹介します。

環境

AWX: 23.9

手順

  1. Zipをバイナリデータに変換するフィルタの作成
  2. Playbookの作成
  3. JobTemplateの作成
  4. JobTemplateの実行
  5. 実行結果からZipを生成,中身の確認

1. Zipをバイナリデータに変換するフィルタの作成

ZipバイナリデータにをBase64でASCII文字列として返すフィルタを作成します
ansibleにはb64encode_filterがありましたが、文字列をエンコードすることしかできないため自作しました
https://docs.ansible.com/ansible/latest/collections/ansible/builtin/b64encode_filter.html

zipファイルをバイナリにエンコードしてから文字列としてデコードすることで、文字列になります。

↑の指摘いただいので修正

zipのバイナリデータをエンコードしてASCIIにするが表現として正しいです。
指摘ありがとうございます。

import base64

def base64_encode_zip(file_path):
  encoded = str()
  try:
    with open(file_path, 'rb') as f:
        bytes = f.read()
        encoded = base64.b64encode(bytes)
    return encoded.decode('utf-8')
  except FileNotFoundError:
    return encoded

class FilterModule(object):
  def filters(self):
      return {
          'base64_encode_zip': base64_encode_zip,
      }

こちらを呼び出せるように配置しansible.cfgで呼び出せるように設定をしておきます。

↑カスタムフィルタではなく、既存のフィルタを組み合わせてもできそうでした! 教えていただいてありがとうございます

2. Playbookの作成

Playbookは、ローカルホストに対してpip freezeコマンドを実行、標準出力をテキストファイルに保存
テキストファイルをZIP化、base64でZIPバイナリを文字列に変換
set_statsでJobTemplateのArtifactsに保存させるものです

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

  tasks:
    - name: get pip freeze
      shell: pip freeze
      register: res_pip_freeze
    
    - name: create text
      copy:
        content: "{{ res_pip_freeze.stdout }}"
        dest: "{{ playbook_dir }}/test.txt"

    - name: archive
      archive:
        dest: "{{ playbook_dir }}/test.zip"
        format: zip
        path:
          - "{{ playbook_dir }}/test.txt"
      
    - name: base64 encord
      set_stats:
        data:
          zip_binary: "{{ (playbook_dir + '/test.zip') | base64_encode_zip }}"

archiveのformat: zipでzipファイルを作れます、pathに定義されたパスのファイルをzipに圧縮します

3. JobTemplateの作成

本題じゃないので割愛

4. JobTemplateの実行

JobTemplateを実行すると、artifactのzip_binaryに文字列が登録されていることが確認できます

出力にはdebugモジュールで出力させたpip freezeの中身も確認できます

5. 実行結果からZipを生成,中身の確認

AWXのREST APIをたたく簡単なスクリプトを作ります JobIDを指定してJobをGET、手元にZIPファイルを作成するスクリプトを作成しました

requests.getでJobを取得し、artifacts内のzip_binaryを文字列からバイナリにエンコードして、 バイナリファイルをZIPファイルとしてでコードしてます

"""
AWXのJobにアクセス、artifactに格納された文字列をbinaryに変換後、zipとして保存する  
保存したzipを展開する  
"""
import base64
import shutil

import requests

AWX_URL = "http://<AWX_IP>/api/v2/"
METHOD = "jobs/54"

REQUEST_URL = AWX_URL+METHOD
response=requests.get(REQUEST_URL, auth=requests.auth.HTTPBasicAuth(<USER>, <PASSWORD>))

json_response = response.json()
str_binary = json_response['artifacts']['zip_binary']
zip_binary = str_binary.encode('utf-8')

# NOTE: zipを格納
outfile_path = "output/artifact.zip"
with open(outfile_path, "wb") as f:
    f.write(base64.b64decode(zip_binary))

# NOTE: zipを解凍
shutil.unpack_archive(outfile_path, 'output/')

スクリプトを実行すると、ZIPと、展開後のtest.txtがoutputファイルに格納されます

test.txtはAWXでローカルホストに向けて実行したpip freezeの結果が確認できます

Pythonの割合がちょっと多くなってしまいました

Ansibleでリスト内辞書のデータをIPアドレス順にソートしたい

Ansible Advent Calendar 2023の4日目の記事です。

qiita.com

概要

リスト内辞書に定義されたIPアドレスの情報をKeyにしてソートしたかったのでやり方を考えてみました。

例えば下記のようなデータです

destination:
  - ip: 10.0.1.1
    name: 宛先2
  - ip: 2.0.1.1
    name: 宛先1

リストを2.0.1.1 -> 10.0.1.1の順番に並び替えをするようなものです。

環境

Python 3.9.16  
ansible [core 2.15.6]
jinja 3.1.2
netaddr 0.9.0
jmespath 1.0.1

手順

オリジナル

まずはsortフィルタのみを使って確認してみます。
用意したPlaybookは以下です、データip以外の要素は使わないので省略しています。

mapフィルタを利用しipの値のみを抜き出したリスト生成しソートしています

- name: 2023 Advent Carender
  hosts: localhost
  gather_facts: false
  tasks:
    - name: set sample data
      ansible.builtin.set_fact:
        data: 
          - ip: 10.0.0.1
          - ip: 10.0.0.11
          - ip: 1.0.0.1
          - ip: 2.0.0.1
          - ip: 2.0.1.1
          - ip: 0.0.0.2
          - ip: 0.0.0.1
          - ip: 0.0.0.10
          - ip: 255.255.255.255
          - ip: 0.0.1.161

    - name: original
      ansible.builtin.debug:
        msg: "{{ data | map(attribute='ip') | sort }}"

結果を見ると、文字列としてソートされているのIPアドレス順にはなっていません。
1.0.0.1のあとは2.0.1.1が来てほしいところです。

PLAY [2023 Advent Carender] ***************************************************************************************************************

TASK [set sample data] ********************************************************************************************************************
ok: [localhost]

TASK [original] ***************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "0.0.0.1",
        "0.0.0.10",
        "0.0.0.2",
        "0.0.1.161",
        "1.0.0.1",
        "10.0.0.1",
        "10.0.0.11",
        "2.0.0.1",
        "2.0.1.1",
        "255.255.255.255"
    ]
}

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

カスタムフィルターを作る

IPアドレスをソートするためにまず思いつたのがカスタムフィルタを作るでした。
pythonの標準ライブラリであるipaddressを使えばIPアドレスを数値に変換することができるので、数値に変換後にソートすることで
IPアドレスをソートができると考えました

import ipaddress


def ip_sort(data):
    return sorted(data, key=lambda x: int(ipaddress.ip_address(x['ip'])))


class FilterModule(object):
    def filters(self):
        return {
            "ip_sort": ip_sort,
        }

カスタムフィルタはplaybookが存在する場所に作成したcustom_filterディレクトリに配置し ansible.cfgを以下のように設定しフィルタを利用可能にします

[defaults]
filter_plugins = custom_filter/

作成したPlaybookは以下です、ip_sortフィルタをかけた時点でソートが完了しているので その後mapフィルタでipの値をリストに抽出し表示します。

---
- name: 2023 Advent Carender Custom Filter
  hosts: localhost
  gather_facts: false
  tasks:
    - name: set sample data
      ansible.builtin.set_fact:
        data: 
          - ip: 10.0.0.1
          - ip: 10.0.0.11
          - ip: 1.0.0.1
          - ip: 2.0.0.1
          - ip: 2.0.1.1
          - ip: 0.0.0.2
          - ip: 0.0.0.1
          - ip: 0.0.0.10
          - ip: 255.255.255.255
          - ip: 0.0.1.161

    - name: custom filter
      ansible.builtin.debug:
        msg: "{{ data | ip_sort | map(attribute='ip') }}"

実行したところ、想定通りにソートされていることが確認できました。 やったね

PLAY [2023 Advent Carender Custom Filter] *************************************************************************************************

TASK [set sample data] ********************************************************************************************************************
ok: [localhost]

TASK [custom filter] **********************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "0.0.0.1",
        "0.0.0.2",
        "0.0.0.10",
        "0.0.1.161",
        "1.0.0.1",
        "2.0.0.1",
        "2.0.1.1",
        "10.0.0.1",
        "10.0.0.11",
        "255.255.255.255"
    ]
}

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

ちなみに、IPアドレスを数値に変換すると0.0.0.0が0に255.255.255.255は4294967295になります。

ipaddrフィルタで頑張る

いきなり、カスタムフィルタを使って解決を目指してしまいましたが、Ansibleが提供している機能だけでどうにかできないか?も考えてみました Ansibleにはipaddrフィルタというものが存在しており、先ほどIPアドレスをIntに変換したのと同じことができます

---
- name: 2023 Advent Carender ipaddr filter
  hosts: localhost
  gather_facts: false
  tasks:
    - name: set sample data
      ansible.builtin.set_fact:
        data: 
          - ip: 10.0.0.1
          - ip: 10.0.0.11
          - ip: 1.0.0.1
          - ip: 2.0.0.1
          - ip: 2.0.1.1
          - ip: 0.0.0.2
          - ip: 0.0.0.1
          - ip: 0.0.0.10
          - ip: 255.255.255.255
          - ip: 0.0.1.161

    - name: ipaddr filter
      ansible.builtin.debug:
        msg:  "{{ data | map(attribute='ip') | ansible.utils.ipaddr('int') | sort | ansible.utils.ipaddr }}"

実行したところ、こちらも想定通りに並び替えができました(まずはこっち思いつきたかった)

PLAY [2023 Advent Carender JQ and ipaddr filter] ******************************************************************************************

TASK [set sample data] ********************************************************************************************************************
ok: [localhost]

TASK [ipaddr filter] *************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "0.0.0.1",
        "0.0.0.2",
        "0.0.0.10",
        "0.0.1.161",
        "1.0.0.1",
        "2.0.0.1",
        "2.0.1.1",
        "10.0.0.1",
        "10.0.0.11",
        "255.255.255.255"
    ]
}

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

おまけ

いままではIPの値の身を抽出した並び替えましたが、ip以外のkeyが定義されていて、そのKeyを取り出したい場合も考えてます。 keyとして追加したnameにはソート後の順番を書いてます、ソートしてnameを取り出したときに1~10まできれいに並べます。
selectattrフィルタで要素を抽出しています。

---
- name: 2023 Advent Carender ipaddr filter
  hosts: localhost
  gather_facts: false
  tasks:
    - name: set sample data
      ansible.builtin.set_fact:
        data: 
          - ip: 10.0.0.1
            name: 8
          - ip: 10.0.0.11
            name: 9
          - ip: 1.0.0.1
            name: 5
          - ip: 2.0.0.1
            name: 6
          - ip: 2.0.1.1
            name: 7
          - ip: 0.0.0.2
            name: 2
          - ip: 0.0.0.1
            name: 1
          - ip: 0.0.0.10
            name: 3
          - ip: 255.255.255.255
            name: 10
          - ip: 0.0.1.161
            name: 4

    - name: ipaddr filter
      ansible.builtin.debug:
        msg: "{{ element.name }}"
      loop: "{{ data | map(attribute='ip') | ansible.utils.ipaddr('int') | sort | ansible.utils.ipaddr }}"
      vars:
        element: "{{ data | selectattr('ip','eq',item) | first }}"

実行結果

PLAY [2023 Advent Carender ipaddr filter] *************************************************************************************************

TASK [set sample data] ********************************************************************************************************************
ok: [localhost]

TASK [ipaddr filter] **********************************************************************************************************************
ok: [localhost] => (item=0.0.0.1) => {
    "msg": "1"
}
ok: [localhost] => (item=0.0.0.2) => {
    "msg": "2"
}
ok: [localhost] => (item=0.0.0.10) => {
    "msg": "3"
}
ok: [localhost] => (item=0.0.1.161) => {
    "msg": "4"
}
ok: [localhost] => (item=1.0.0.1) => {
    "msg": "5"
}
ok: [localhost] => (item=2.0.0.1) => {
    "msg": "6"
}
ok: [localhost] => (item=2.0.1.1) => {
    "msg": "7"
}
ok: [localhost] => (item=10.0.0.1) => {
    "msg": "8"
}
ok: [localhost] => (item=10.0.0.11) => {
    "msg": "9"
}
ok: [localhost] => (item=255.255.255.255) => {
    "msg": "10"
}

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

ほかにもjson_queryフィルタを使っても同じようなこともできますね。

    - name: json_query
      ansible.builtin.debug:
        msg: "{{ data | community.general.json_query('[*].ip') | ansible.utils.ipaddr('int') | sort | ansible.utils.ipaddr }}"

以上、小ネタでした~

ansible.utilsコレクションのフィルタ(keep_keys/remove_keys/replace_keys)を試す

この記事はアドベントカレンダー20日目の記事です

qiita.com

はじめに

最近showコマンドの結果をパースして、パースした結果をこねくり回すことが増えてきました。 そんな中で、特定のKeyのみを残す、特定のKeyを削除する必要があり自作フィルタを使っていました 。

ansible.utils 2.5.0(2022-01-31リリース)から追加されたxxx_keys系のフィルタが自分が作っていたものとほぼ同じ動きをするフィルタだったので動作確認をして置き換えましたので紹介します。

昨年の記事で試したrouteのDiffをとる前に、データ操作としてJinja2テンプレートをいじったり、カスタムフィルタを使ったりしましたが、別解のようなイメージです。

usage-automate.hatenablog.com

keep_keysフィルタ

文字通り、指定されたKeyのみを保持するフィルタです

サンプルプレイブック

---
- hosts: localhost
  gather_facts: false

  vars:
    routes:
      - network: "195.0.0.0"
        distance: "110"
        mask: "24"
        metric: "11"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "IA"
        uptime: "00:05:45"
      - network: "0.0.0.0"
        distance: "110"
        mask: "0"
        metric: "1"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "E2"
        uptime: "00:05:35"

  tasks:
    - name: keep_keys
      ansible.builtin.debug:
        msg: "{{ routes | ansible.utils.keep_keys(target=['network','distance']) }}"

フィルタの変数targetにリストで渡した文字列のKeyのみを保持します

targerはregrexやstarts_with/ends_withなど正規表現を使ってマッチさせることも可能です

keep_keys(target=['^ne','distance'], matching_parameter= 'starts_with')

実行結果

TASK [keep_keys] ************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "network": "195.0.0.0"
        },
        {
            "distance": "110",
            "network": "0.0.0.0"
        }
    ]
}

targetsに指定したnetwork/distanceのみのデータに整形できました

remove_keysフィルタ

こちら指定したキーを削除するフィルタになります

uptimeを取り除く例

---
- hosts: localhost
  gather_facts: false

  vars:
    routes:
      - network: "195.0.0.0"
        distance: "110"
        mask: "24"
        metric: "11"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "IA"
        uptime: "00:05:45"
      - network: "0.0.0.0"
        distance: "110"
        mask: "0"
        metric: "1"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "E2"
        uptime: "00:05:35"

  tasks:
    - name: remove_keys
      ansible.builtin.debug:
        msg: "{{ routes | ansible.utils.remove_keys(target=['uptime']) }}"

keep_keysフィルタと同様に変数targetにリストで渡します
matching_parameterも同様に利用可能です

出力

TASK [remove_keys] *************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "mask": "24",
            "metric": "11",
            "network": "195.0.0.0",
            "nexthop_if": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "IA"
        },
        {
            "distance": "110",
            "mask": "0",
            "metric": "1",
            "network": "0.0.0.0",
            "nexthop_if": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "E2"
        }
    ]
}

フィルタにかけた後のデータからuptimeが消えたことが確認できます

replace_keysフィルタ

replace_keysフィルタはKeyの名前を変えることができるフィルタです

---
- hosts: localhost
  gather_facts: false

  vars:
    routes:
      - network: "195.0.0.0"
        distance: "110"
        mask: "24"
        metric: "11"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "IA"
        uptime: "00:05:45"
      - network: "0.0.0.0"
        distance: "110"
        mask: "0"
        metric: "1"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "E2"
        uptime: "00:05:35"

  tasks:
    - name: replace_keys
      ansible.builtin.debug:
        msg: "{{ routes | ansible.utils.replace_keys(target=[{'before':'nexthop_if', 'after':'nexthop_interface'}]) }}"

フィルタの変数targetsには[{before: XXX, after: XXXZ}]とどのKeyを何に変えるかを定義する必要があります
matching_parameterはkeep_keys/remove_keysと同様に利用可能です

nexthop_ifのKeyをnexthop_interfaceに変更します。

出力

TASK [replace_keys] *********************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "mask": "24",
            "metric": "11",
            "network": "195.0.0.0",
            "nexthop_interface": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "IA",
            "uptime": "00:05:45"
        },
        {
            "distance": "110",
            "mask": "0",
            "metric": "1",
            "network": "0.0.0.0",
            "nexthop_interface": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "E2",
            "uptime": "00:05:35"
        }
    ]
}

nexthop_ifをnexthop_interfaceに変更できました。

まとめ

ansible.utilsの3つのフィルタを紹介しました
コレクションで提供されている便利なフィルタは積極的に使っていきたいですね。

ちなみに

書いてる時に思い出しましたがすべて、json_queryでもできますね笑

---
- hosts: localhost
  gather_facts: false

  vars:
    routes:
      - network: "195.0.0.0"
        distance: "110"
        mask: "24"
        metric: "11"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "IA"
        uptime: "00:05:45"
      - network: "0.0.0.0"
        distance: "110"
        mask: "0"
        metric: "1"
        nexthop_if: "FastEthernet0/0.100"
        nexthop_ip: "194.0.0.2"
        protocol: "O"
        type: "E2"
        uptime: "00:05:35"

  tasks:
    - name: json_query(keep_keys)
      ansible.builtin.debug:
        msg: "{{ routes | community.general.json_query(query_string) }}"
      vars:
        query_string: "[*].{network: network, distance: distance}"

    - name: json_query(remove_keys)
      ansible.builtin.debug:
        msg: "{{ routes | community.general.json_query(query_string) }}"
      vars:
        query_string: "[*].{network: network,
                            distance: distance,
                            mask: mask,
                            metric: metric,
                            nexthop_if: nexthop_if,
                            nexthop_ip: nexthop_ip,
                            protocol: protocol,
                            type: type}"

    - name: json_query(replace_keys)
      ansible.builtin.debug:
        msg: "{{ routes | community.general.json_query(query_string) }}"
      vars:
        query_string: "[*].{network: network,
                            distance: distance,
                            mask: mask,
                            metric: metric,
                            nexthop_interface: nexthop_if,
                            nexthop_ip: nexthop_ip,
                            protocol: protocol, 
                            type: type}"

実行結果

TASK [json_query(keep_keys)] ***************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "network": "195.0.0.0"
        },
        {
            "distance": "110",
            "network": "0.0.0.0"
        }
    ]
}

TASK [json_query(remove_keys)] **************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "mask": "24",
            "metric": "11",
            "network": "195.0.0.0",
            "nexthop_if": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "IA"
        },
        {
            "distance": "110",
            "mask": "0",
            "metric": "1",
            "network": "0.0.0.0",
            "nexthop_if": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "E2"
        }
    ]
}

TASK [json_query(replace_keys)] ***************************************************************************************************************
ok: [localhost] => {
    "msg": [
        {
            "distance": "110",
            "mask": "24",
            "metric": "11",
            "network": "195.0.0.0",
            "nexthop_interface": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "IA"
        },
        {
            "distance": "110",
            "mask": "0",
            "metric": "1",
            "network": "0.0.0.0",
            "nexthop_interface": "FastEthernet0/0.100",
            "nexthop_ip": "194.0.0.2",
            "protocol": "O",
            "type": "E2"
        }
    ]
}

Ansibleのカスタムフィルターを作る

この記事はエーピーコミュニケーションズ Advent Calendar 2022の16日目の記事です qiita.com

やりたいこと

Xmasといったらサンタですね。

大人になってからというものサンタにあっていません。

豊富なモジュールやフィルタがあるAnsibleでも
サンタに会えるモジュール・フィルタはありませんでした。

せっかくなので
自作のフィルタ(カスタムフィルター)を作成してサンタと会いたいと思います。

スクリプト書く

いきなりAnsibleのフィルタにするのではなく、まずはPythonスクリプトでJinja2Templateを呼び出し
その中で自作したXmasフィルタを動かします。

xmas.py

/で区切ったYYYY/MM/DDのデータを受け取り
MMが12、DDが25の場合のみサンタに会えるフィルタを作成します。

import datetime

def Xmas(*args, **kwargs):
  split_arg = args[0].split('/')
  try:
    date = datetime.date(int(split_arg[0]),int(split_arg[1]),int(split_arg[2]))
    if date.month == 12 and date.day == 25:
      res = '''
           [ 内緒 ]
            '''
      return res
    else:
      res = '''
        サンタかと思ったか?馬鹿め!それは残像だ!
         -= ∧ ∧
        -=と( ・∀・)
         -=/ と_ノ
        -=_//⌒ソ
      '''
      return res
  except IndexError:
    return args[0]

処理は簡単に、受け取った文字列を/で分割して12月25日を判断しています。
[ 内緒 ]の部分にはサンタのAAが入っていますが、最後の表示の楽しみということにします。

script.py

from jinja2 import Environment, FileSystemLoader
from xmas import Xmas

if __name__ == '__main__':
  j2_env = Environment(loader=FileSystemLoader('template/',encoding='utf-8'))
  j2_env.filters['Xmas'] = Xmas

  template = j2_env.get_template('stdout.j2')
  render = template.render(data="2022/12/15")

  print(render)

j2_env.filter[<フィルタ名>] = 関数と定義することで自作のフィルターをテンプレートで利用可能となります
dataは"2022/12/15"のため、まだサンタに会えないのが想定になります

template/stdout.j2

{{ data | Xmas }}

ディレクトリ内は以下のような配置になっています

.
├── script.py
├── template
│   └── stdout.j2
└── xmas.py

スクリプトを実行する


まだサンタには会えていませんが、自作したフィルタが動くことが確認できました。

カスタムフィルターにする

続いて、作成したカスタムフィルタをAnsibleで読み込めるようにします

xmas.py

import datetime

def Xmas(*args, **kwargs):
  split_arg = args[0].split('/')
  try:
    date = datetime.date(int(split_arg[0]),int(split_arg[1]),int(split_arg[2]))
    if date.month == 12 and date.day == 25:
      res = '''
           [ 内緒 ]
            '''
      return res
    else:
      res = '''
        サンタかと思ったか?馬鹿め!それは残像だ!
         -= ∧ ∧
        -=と( ・∀・)
         -=/ と_ノ
        -=_//⌒ソ
      '''
      return res
  except IndexError:
    return args[0]

class FilterModule(object):
  def filters(self):
      return {
          'Xmas': Xmas,
      }

class FilterModuleを追記します。
関数filtersの戻り値でJinja2で利用可能なフィルタ名とスクリプトないの関数を紐つけています。
書き方はbuiltinのcore.pyを参考にしています
docs.ansible.com

github.com

Ansibleで実行する

さて、これでカスタムフィルターは作成できました
続けて実際にPlaybook内で自作したフィルターを呼び出してみたいと思います。

---
- name: Xmas?
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    date: 2022/12/15

  tasks:
    - name: Xmas?
      debug:
        msg: "{{ date | Xmas }}"

実行してみる

エラーになりました、 自作したカスタムフィルタを使うにはansible.cfgでfilter_pluginsを指定する必要があります

[defaults]
filter_plugins = .

再実行


カスタムフィルターは動きましたが、一行で表示されているためせっかくのAAが確認できません

せっかくなのでstdout_callback_pluginをYAMLにします
yamlコールバックプラグインcommunity.generalコレクションに入っています。ない場合は事前にインストールします

[defaults]
filter_plugins = .
stdout_callback = community.general.yaml

再々実行
バッチリですね

インプットは12/15なのでクリスマスではなくサンタには会えませんでした。

幸せになる

未来を先取りして、変数を12/25にします

---
- name: Xmas?
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    date: 2022/12/25

  tasks:
    - name: Xmas?
      debug:
        msg: "{{ date | Xmas }}"

実行してみます

   サンタに会えました。最高です。

おまけ

  • ansible.cfgをいじりたくない場合はデフォルトでfilter参照するパスに直接配置します
ansible-config dumps
DEFAULT_FILTER_PLUGIN_PATH(default) = ['/Users/kouta/.ansible/plugins/filter', '/usr/share/ansible/plugins/filter']

フィルターを配置

(v_ansible) MBA:CustomFilter_2022 kouta$ cp xmas.py /Users/kouta/.ansible/plugins/filter/xmas.py
  • collectionにして配布したい ansible-collectionにして配布したい場合は、ansible-galaxy collection init <コレクション名>で生成されたディレクトリのplugins/filter配下に自作のフィルターを配置します
    usalab.xmasコレクションを作成し、スクリプトを配置しました

コレクションを配置したパスをansible.cfgで指定します。 指定したパスのディレクトは以下のような構成になっている必要があるようです
<指定したパス>/ansible_collection/<自作したコレクション>

[defaults]
filter_plugins = filter_plugins
stdout_callback = community.general.yaml
collections_paths = collections

せっかくなので、filterに引数が追加で渡されるパターンで拡張してみます 利用イメージとしては、以下のようにフィルターを使うにあたって2つの引数がある(1つめdate,2つめ'santa') {{ date | Xmas('santa') }}

第2引数の文字列がsantaもしくはreindeer(トナカイ)の文字列に応じて返す値を変えます

import datetime

def Xmas(*args, **kwargs):
  split_arg = args[0].split('/')
  key = args[1]
  try:
    date = datetime.date(int(split_arg[0]),int(split_arg[1]),int(split_arg[2]))
    if date.month == 12 and date.day == 25:
      if key == 'santa':
        res = '''
              [内緒]
              '''
      elif key == 'reindeer':
        res = '''
              [内緒]
              '''
      return res
    else:
      res = '''
        サンタかと思ったか?馬鹿め!それは残像だ!
         -= ∧ ∧
        -=と( ・∀・)
         -=/ と_ノ
        -=_//⌒ソ
      '''
      return res
  except IndexError:
    return args[0]

class FilterModule(object):
  def filters(self):
      return {
          'Xmas': Xmas,
      }

プレイブックは以下のように記載します usalab.xmasにあるXmasフィルタを呼び出します

---
- name: Xmas?
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    date: 2022/12/25

  tasks:
    - name: Xmas?_サンタ
      debug:
        msg: "{{ date | usalab.xmas.Xmas('santa') }}"

    - name: Xmas?_トナカイ
      debug:
        msg: "{{ date | usalab.xmas.Xmas('reindeer') }}"

バッチリ動きますね

※ansible.cfgでcollections_pathを指定した場合、指定したパスに存在するコレクションのみが対象になるようです、コールバックプラグインで利用しているcommunity.generalコレクションもcollections_pathで指定したディレクトリに含める必要があります。