zaki work log

作業ログやら生活ログやらなんやら

[NetBox] NetBox(v2.10.3 → v2.10.4)とnetbox-docker(0.27.0 → 1.0.2)のアップグレード

Docker ComposeでデプロイしたNetBoxをバージョンアップする。
基本的には「動作中NetBoxを停止 -> Composeファイルを(リポジトリごと)更新 -> 新バージョンNetBoxを起動」で良いが、更新内容によっては追加手順があるのでここ最近のバージョンから最新安定版へのアップグレード時のチェック箇所について簡単にまとめた。

見るべきポイントは基本的にGitHubリポジトリのReleasesページ。

github.com

詳細が必要な場合はNetBox本体のドキュメントも参照する。

netbox.readthedocs.io

本記事はnetbox-dockerのversin 0.27.0 から現バージョンの1.0.2へのアップグレード例。
それ以前からのアップグレードについてはわかる範囲で調査・検証した。

現バージョンの確認

確認するバージョンはNetBox本体とnetbox-dockerのバージョンの両方を確認。
Docker版NetBoxは、NetBox本体のバージョンと、Docker Composeとしてのバージョンの2系統あるためちょっとややこしいので注意。
※ Docker Composeのバージョンは「Docker版NetBoxのデプロイツールとしてのバージョン (これを使ってNetBox本体をデプロイする)」と捉えるとシックリくるかも

NetBox本体は、webアクセスした際のページヘッダに表示される。

f:id:zaki-hmkc:20210223155117p:plain

6e5c27b39de8 (v2.10.3)

netbox-dockerのバージョンは、cloneしたリポジトリ直下のVERSIONファイルを確認する。

[zaki@manager netbox-docker]$ cat VERSION 
0.27.0

アップグレード内容の確認

GitHubのReleaseの内容を確認する。

github.com

ここで upgrad compati earlier あたりの単語検索しておく。

NetBox本体の互換性

Release Noteの各バージョンの「Compatibility」を確認。

例えばNetBox本体がv2.9.xからのアップグレードの場合は、「Version 0.26.2はNetBox v2.9.xと互換あり」の状態から、その次の「Version 0.27.0はNetBox 2.10.xと互換あり」になっているため、このバージョンをまたぐアップグレードの場合(netbox-dockerのバージョン0.26.2以前からのアップグレードの場合)は、内容をよく確認しておく。

このバージョンの場合はinitializersを使用している場合に追加手順が必要。
DBのマイグレーションとあるので、DBコンテナのみ起動してDjangomanage.pyを実行すればよさそう。(詳細未確認)

github.com

github.com

PostgreSQLのバージョン

比較的目立たない気がするけど重要な件で、netbox-dockerのVersion 0.26.0以降では使用するDBが、PostgreSQL 11からPostgreSQL 12に変更されている。

このアップグレードはどちらかの対応が必要。
(11のデータのままだと12のPostgreSQLは利用できない)

PostgreSQL 11のまま使用する場合は、docker-compose.override.ymlで設定を上書きし、postgres:11-alpineを使用するようにすれば動作する。
DBのバックアップ&リストアによる更新は後述。

アップグレード

まず現バージョンのNetBoxを停止。 (git pullするとdocker-compose.ymlが更新されてしまうので先に停止する。先にgit pullしてしまった場合は、git checkout <tagname>で戻してからdocker-compose downする)

$ docker-compose down
Stopping netbox-docker_nginx_1         ... done
Stopping netbox-docker_netbox_1        ... done
Stopping netbox-docker_netbox-worker_1 ... done
Stopping netbox-docker_postgres_1      ... done
Stopping netbox-docker_redis_1         ... done
Stopping netbox-docker_redis-cache_1   ... done
Removing netbox-docker_nginx_1         ... done
Removing netbox-docker_netbox_1        ... done
Removing netbox-docker_netbox-worker_1 ... done
Removing netbox-docker_postgres_1      ... done
Removing netbox-docker_redis_1         ... done
Removing netbox-docker_redis-cache_1   ... done
Removing network netbox-docker_default

停止したことを確認。
DBをバックアップする場合はこのタイミングでDBのコンテナのみ起動してpg_dumpする。

$ docker-compose ps
Name   Command   State   Ports
------------------------------

リポジトリを更新。

$ cat VERSION 
0.27.0
$ git pull
remote: Enumerating objects: 263, done.
remote: Counting objects: 100% (263/263), done.
remote: Compressing objects: 100% (96/96), done.
remote: Total 433 (delta 175), reused 228 (delta 162), pack-reused 170
Receiving objects: 100% (433/433), 85.81 KiB | 285.00 KiB/s, done.
Resolving deltas: 100% (260/260), completed with 50 local objects.
From https://github.com/netbox-community/netbox-docker
   aa4d630..c80fb19  release    -> origin/release
   4e8588a..009eb1f  develop    -> origin/develop
 * [new tag]         1.0.2      -> 1.0.2
 * [new tag]         1.0.0      -> 1.0.0
 * [new tag]         1.0.1      -> 1.0.1
Updating aa4d630..c80fb19
Fast-forward
 .ecrc                                                 | 23 +++++++++++++++++++++++
 .editorconfig                                         | 11 +++++++++++

 :
 :
$ cat VERSION 
1.0.2

ここでnetbox-docker version 1.0.0未満からのアップグレードの場合はNginxコンテナを使わなくなっているため、docker-compose.override.ymlに上書き設定を記述している場合は更新する。(ここが元のままだと次のdocker-compose pullが失敗する)

  version: '3.4'
  services:
-   nginx:
+   netbox:
      ports:
      - '8080:8080'

latestの中身が更新されているのでイメージをpullする。

$ docker-compose pull
Pulling postgres      ... waiting
Pulling redis-cache   ... waiting
Pulling redis         ... waiting
Pulling netbox-worker ... downloading (86.6%)
Pulling netbox        ... downloading (86.6%)

DBのバージョンを更新した場合はこのタイミングでリストアする。
準備できたらデプロイする。

$ docker-compose up -d
Creating network "netbox-docker_default" with the default driver
Creating netbox-docker_postgres_1    ... done
Creating netbox-docker_redis_1       ... done
Creating netbox-docker_redis-cache_1 ... done
Creating netbox-docker_netbox-worker_1 ... done
Creating netbox-docker_netbox_1        ... done

f:id:zaki-hmkc:20210223161309p:plain

アップグレードできた。

バックアップ・リストアによるPostgreSQL 12へのデータ移行

PostgreSQL 12へ移行する場合は、ver11のDBデータをバックアップし、ver12でリストアすると良い。

Troubleshooting / Database Operations

NetBoxを一度停止。

$ docker-compose stop
Stopping netbox-docker_nginx_1         ... done
Stopping netbox-docker_netbox_1        ... done
Stopping netbox-docker_netbox-worker_1 ... done
Stopping netbox-docker_redis-cache_1   ... done
Stopping netbox-docker_postgres_1      ... done
Stopping netbox-docker_redis_1         ... done

pg_dumpを使ったデータのバックアップのためDBのみ起動。

$ docker-compose up -d postgres
Starting netbox-docker_postgres_1 ... done
$ docker-compose ps
            Name                           Command               State     Ports  
----------------------------------------------------------------------------------
netbox-docker_netbox-worker_1   python3 /opt/netbox/netbox ...   Exit 0           
netbox-docker_netbox_1          /opt/netbox/docker-entrypo ...   Exit 0           
netbox-docker_nginx_1           nginx -c /etc/netbox-nginx ...   Exit 0           
netbox-docker_postgres_1        docker-entrypoint.sh postgres    Up       5432/tcp
netbox-docker_redis-cache_1     docker-entrypoint.sh sh -c ...   Exit 0           
netbox-docker_redis_1           docker-entrypoint.sh sh -c ...   Exit 0   

DBにアクセスできることを確認。

$ source env/postgres.env 
$ docker-compose exec postgres sh -c 'psql -U $POSTGRES_USER $POSTGRES_DB'
psql (11.11)
Type "help" for help.

netbox=# 

PostgreSQLのデータバックアップ。

$ docker-compose exec -T postgres sh -c 'pg_dump -cU $POSTGRES_USER $POSTGRES_DB' | gzip > db_dump.sql.gz
ls -l db_dump.sql.gz
-rw-rw-r--. 1 zaki zaki 48388  2月 23 15:19 db_dump.sql.gz

データのバックアップが終わったらPostgreSQLを停止、アップグレードしたPostgreSQL 12でPostgreSQL 11のデータを読み込むとエラーになるため、ボリュームも削除する。

$ docker-compose down
Stopping netbox-docker_postgres_1 ... done
Removing netbox-docker_nginx_1         ... done
Removing netbox-docker_netbox_1        ... done
Removing netbox-docker_netbox-worker_1 ... done
Removing netbox-docker_redis-cache_1   ... done
Removing netbox-docker_postgres_1      ... done
Removing netbox-docker_redis_1         ... done
Removing network netbox-docker_default
$ docker volume ls | grep netbox | grep postgres
local     netbox-docker_netbox-postgres-data
$ docker volume rm netbox-docker_netbox-postgres-data
netbox-docker_netbox-postgres-data

この状態でgit pull, docker-compose pullリポジトリ・イメージを更新し、まずはDBをリストアするためにPostgreSQLのみデプロイしてデータをリストアする。 (docker-compose.override.ymlnetboxの上書き設定がある場合はnetboxに修正する)

$ docker-compose up -d postgres
Creating network "netbox-docker_default" with the default driver
Creating volume "netbox-docker_netbox-postgres-data" with local driver
Creating netbox-docker_postgres_1 ... done
$ gunzip -c db_dump.sql.gz | docker-compose exec -T postgres sh -c 'psql -U $POSTGRES_USER $POSTGRES_DB' 
SET
SET
SET
SET
SET
 set_config 
------------
 
(1 row)

:
:

残りのコンテナも起動する。

$ docker-compose up -d 
netbox-docker_postgres_1 is up-to-date
Creating netbox-docker_redis-cache_1 ... done
Creating netbox-docker_redis_1       ... done
Creating netbox-docker_netbox-worker_1 ... done
Creating netbox-docker_netbox_1        ... done
$ docker-compose ps
            Name                           Command               State                       Ports                     
-----------------------------------------------------------------------------------------------------------------------
netbox-docker_netbox-worker_1   /opt/netbox/venv/bin/pytho ...   Up                                                    
netbox-docker_netbox_1          /opt/netbox/docker-entrypo ...   Up      0.0.0.0:8099->8080/tcp,0.0.0.0:49177->8080/tcp
netbox-docker_postgres_1        docker-entrypoint.sh postgres    Up      5432/tcp                                      
netbox-docker_redis-cache_1     docker-entrypoint.sh sh -c ...   Up      6379/tcp                                      
netbox-docker_redis_1           docker-entrypoint.sh sh -c ...   Up      6379/tcp   

これでPostgreSQL 12にデータを引き継げるはず。

注意点としてはドキュメントの以下記述。

  • If your database is not too big, a full backup (with PostgreSQL 11, i.e. before the upgrade) and restore (with a clean PostgreSQL 12, i.e. after the upgrade) is probably the easiest to achieve
  • Please test the procedure on a test system first!

データがデカすぎると…リソースを喰いすぎるのかな?
あとテストをよく実施するように、ということ。

※ 上記も適当なデータ入れた状態で試したレベルなので、実際にやる場合はよく検証してください。

検証のため古いバージョンでデプロイする

リポジトリバージョンをターゲットに合わせてcheckoutする。

$ git checkout 0.26.2

また、これだけだとコンテナイメージはlatestを持ってきてしまうため、環境変数VERSIONv2.9など必要に応じて設定すれば指定バージョンでデプロイできる。

services:
  netbox: &netbox
    image: netboxcommunity/netbox:${VERSION-latest}

[ssh / docker / kubectl] ローカルの標準出力をリモートの標準入力にする

sshdocker execkubectl execで、シェルを起動せずに直接コマンドを実行できるが、sshなどを実行するローカルの標準出力をリモートホストやコンテナの標準入力として処理する方法について。

SSHの場合

前提。
ローカルのhostnameはcloud-devで、リモートはrhel8となっている。

[zaki@cloud-dev ~]$ hostname
cloud-dev
[zaki@cloud-dev ~]$ ssh 192.168.0.27 hostname
rhel8

ローカルのhostnameコマンド実行結果をリモートにパイプし、リモートではそれを標準入力(-)から受けてファイルへリダイレクト。(コマンドにあまり意味はない)

[zaki@cloud-dev ~]$ hostname | ssh 192.168.0.27 sh -c 'cat - > /tmp/hostname.txt'

結果、リモートでは/tmp/hostname.txtが生成され、ローカルのhostnameの結果が保存される。
ローカルにはこのファイルは無い。

[zaki@cloud-dev ~]$ ssh 192.168.0.27 cat /tmp/hostname.txt
cloud-dev
[zaki@cloud-dev ~]$ ls /tmp/hostname.txt
ls: /tmp/hostname.txt にアクセスできません: そのようなファイルやディレクトリはありません

Dockerのコンテナの場合

前提。
debianイメージでデプロイしたコンテナIDa24d81dbfee2のコンテナが動作している。
コンテナでhostnameを実行するとコンテナIDと同じになっている。

[zaki@cloud-dev ~]$ docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS        PORTS     NAMES
a24d81dbfee2   debian     "tail -f /dev/null"      2 seconds ago   Up 1 second             relaxed_heisenberg
[zaki@cloud-dev ~]$ docker exec -i a24d hostname
a24d81dbfee2

前述SSHの例と同様に、ローカルのhostnameの実行結果をパイプでdocer execで実行中コンテナの標準入力としてファイルへリダイレクト。
ポイントは-iを付与すること。これが無いと標準入力を引き継げずに入力がなくなり、空ファイルが生成される。
また、-iとセットで使うことの多い-tは、付与するとthe input device is not a TTYとエラーになるので使用しない。

[zaki@cloud-dev ~]$ hostname | docker exec -i a24d sh -c 'cat - > /tmp/hostname.txt'

結果、コンテナ内の/tmp/hostname.txtにローカルのコマンド実行結果が保存される。

[zaki@cloud-dev ~]$ docker exec a24d cat /tmp/hostname.txt
cloud-dev
[zaki@cloud-dev ~]$ ls /tmp/hostname.txt
ls: /tmp/hostname.txt にアクセスできません: そのようなファイルやディレクトリはありません

KubernetesのPodの場合

前提。
HTTPサーバーのPodであるsample-httpが動作している。
Pod名はsample-http-6c94f59975-w89gdでPod内コンテナのホスト名も同じ名称になっている。

[zaki@cloud-dev ~]$ kubectl get pod
NAME                            READY   STATUS    RESTARTS   AGE
sample-http-6c94f59975-w89gd    1/1     Running   0          7d12h
[zaki@cloud-dev ~]$ kubectl exec sample-http-6c94f59975-w89gd -- hostname
sample-http-6c94f59975-w89gd

SSHとDockerの例同様、ローカルのhostnameの実行結果をPodへパイプし、Pod内でファイルへリダイレクトしてみる。
ポイントはDocker同様に-iを付与すること。これが無いと標準入力を引き継げずに入力がなくなり、空ファイルが生成される。
また、やはりdocker execと同様に-tを併用すると Unable to use a TTY - input is not a terminal or the right kind of fileというエラーになるので使用しない。

[zaki@cloud-dev ~]$ hostname | kubectl exec -i sample-http-6c94f59975-w89gd -- sh -c 'cat - > /tmp/hostname.txt'
[zaki@cloud-dev ~]$ kubectl exec sample-http-6c94f59975-w89gd -- cat /tmp/hostname.txt
cloud-dev
[zaki@cloud-dev ~]$ ls /tmp/hostname.txt
ls: /tmp/hostname.txt にアクセスできません: そのようなファイルやディレクトリはありません

使いどころ

個人的によく使うパターンは、ローカルにあるtar.gzのアーカイブファイルを、「リモートに転送してリモート上で展開せず」に、「tar.gzの中身を標準出力で転送してリモートで展開する」ことで、リモート上にtar.gzのアーカイブファイルそのものの転送と展開後の削除を省略したりする。
同じように、(この文脈だと紛らわしいけど)ローカルのコンテナイメージをdocker saveの結果をファイルではなく標準出力でsshにパイプし、リモートでdocker loadすることでイメージのtarファイル作成・転送・展開を一気にできる。

コンテナの場合も、例えばDBの初期構築用SQLファイルをローカルから一気にコンテナ内のコマンドに流したりできる。

SSHは昔から(横着したいときに特に)よく使ってたんだけど、そういえばDockerやKubernetesで同じことできるんだっけ?と思って試してみたらちゃんとできた。

[Ansible / Jinja2] select / selectattr を使った配列と辞書のフィルタリング

配列や、辞書の配列の中から特定の条件の要素を抜き出す。
配列操作はselect()で要素に対してフィルター処理を行い、辞書操作はselectattr()でkey指定することでvalueにフィルター処理を行う。

ここでいうフィルター処理は、AnsibleやJinja2のフィルターのことではなく、AnsibleやJinja2のTest(被演算子/operatorと言うと個人的にはわかりやすいんだけど、「被演算子」という表記は特に無く、Jinja2だとisを使った被演算子の方で関数的に使用)を使って要素をフィルタリングすることを指す。

Ansibleで変数名 is HogeHoge(...)という書式で使われているHogeHoge()の部分。 これをselect()selectattr()で使って配列・辞書に対してまとめて処理可能になっている (はず)。

select()selectattr()は、どちらもJinja2テンプレートのサイトにBuiltin Filtersとして記載があるのでそちらを参照。

jinja.palletsprojects.com

この記事では、以下のバージョンを前提としている。

  • Ansible: 2.10.5
  • Python: 3.6.8
  • Jinja2: 2.11.2
  • OS: CentOS 7.9.2009

配列 (select)

指定したTestの演算結果がtrueの場合にその要素を返す。
って書くとなんか分かりづらいけど、要は条件に合致した配列要素の場合のみ、その要素を返す。

数値 (一致・大小)

比較演算子が使える。

  vars:
    integer_values:
      - 0
      - 1
      - 2
      - 3
      - 4

  - name: select integer values
    debug:
      msg:
      - '{{ integer_values | select("eq", 2) }}'  # 2と同値か  => [2]
      - '{{ integer_values | select("==", 2) }}'  # "eq"と同じ
      - '{{ integer_values | select("le", 3) }}'  # 3以下か    => [0,1,2,3]
      - '{{ integer_values | select("<=", 3) }}'  # "le"と同じ
      - '{{ integer_values | select("lt", 3) }}'  # 3未満か    => [0,1,2]
      - '{{ integer_values | select("<", 3) }}'   # "lt"と同じ

文字列 (一致・大小)

数値の場合と同様、eqまたは==で一致した場合。
(intは==、strはeqみたいに、どっちかに統一して欲しかったな、これ。。)

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2

  tasks:
  - name: split filter sample
    debug:
      msg:
      - '{{ hosts | select("eq", "web1") }}'  # web1が取れる

文字列に対してはあまり使う頻度は無いと思うが、gt/>, ge/>=, lt/<, le/<= も使える。

正規表現

matchsearchを使った正規表現も使える。
正規表現はJinja2のTestでなく、Ansibleで用意されている。
処理内容はPythonPattern#match()Pattern#search()となっている。

Testing strings

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2
      - web1
      - dns

  tasks:
  - name: split filter sample
    debug:
      msg:
      - '{{ hosts | select("match", "web") }}' # webにmatchするもの
      - '{{ hosts | select("match", "b") }}'   # bが含まれるもの…と思いきやre.match("b")っぽい。bで始まるもののみ
      - '{{ hosts | select("search", "b") }}'  # bがどこかに含まれるもの re.search("b")っぽい
      - '{{ hosts | select("regex", "b") }}'   # デフォルトでは"search"と同じように動作。

また、regexもあり、これはmatch_typeという名前付き引数を指定することで、Pythonのreで使用するメソッドを指定できる。

'{{ hosts | select("regex", "d.*\d+", match_type="fullmatch") }}'   # match_typeでメソッド指定

docs.python.org

型でフィルタリング

配列要素として数値や文字列・booleanなどバラバラの型の値が入っている場合に、型でフィルタリングもできる。
型名の文字列のみを引数に指定する。

    type_mix:
      - 1
      - 12
      - a
      - ab
      - curry
      - 755
      - true
      - false
      - 3.14
      - "true"
      - "false"
      - "123"
      - "3.14"
      - - subitem1
        - subitem2
      - key: 1
        value: 2

- name: select values type
    debug:
      msg:
      - '{{ type_mix | select("string") }}'   # curry, 'trye', ...
      - '{{ type_mix | select("integer") }}'  # 755
      - '{{ type_mix | select("number") }}'   # 755, true, false, 3.14
      - '{{ type_mix | select("boolean") }}'  # true, false
      - '{{ type_mix | select("float") }}'    # 3.14
      - '{{ type_mix | select("sequence") }}' # リストだけではなく、文字列・リスト・辞書が取れる
      - '{{ type_mix | select("mapping") }}'  # {key, value}

sequenceのみ思ってたのと違った。。
(iterateとして処理できるとtrueになるため、辞書だけでなく文字列もヒットする)

また、numberはboolean値もヒットするので注意。
型というより、数値として処理できる変数であれば対象という感じ?(未確認)

ファイルパス

実行ホスト上に指定されたパスが存在するかどうかでフィルタリングできる。

Testing paths

  - name: file path test
    vars:
      path_list:
        - /
        - /bin
        - /usr/bin/perl
        - /usr/bin/python3
        - /not-found-path
    debug:
      msg:
      - '{{ path_list | select("directory") }}'  # /, /bin
      - '{{ path_list | select("file") }}'       # perl, python3
      - '{{ path_list | select("link") }}'       # /bin, python3
      - '{{ path_list | select("exists") }}'     # /not-found-path 以外
      - '{{ path_list | select("mount") }}'      # /

バージョン文字列

「〇〇が未インストール、または、インストール済みでバージョン□□以下の場合」みたいなことができる。

Comparing versions

  - name: version sample
    vars:
      version_string:
        - 1.0.0
        - 1.0.99999999999999999
        - 1.1.0
        - 1.1.1
        - 1.01.0
        - 1.01.1
        - 1.9.0
        - 1.10.0
        - '1.0.1'
        - '1.1.1'
        - '1.1'
    debug:
      msg:
      - '{{ version_string | select("version", "1.1.0", "gt") }}'  # ['1.1.1', '1.01.1', '1.9.0', '1.10.0', '1.1.1']

1.1.0との比較で、1.a.0を入力するとエラーになる。

fatal: [localhost]: FAILED! => 
  msg: 'Version comparison: ''<'' not supported between instances of ''str'' and ''int'''

versonについてはPythonのドキュメントに2021.02.16時点で記載がなぜか抜けているため、詳細はソースを見るしかない?

docs.python.org

StrictVersionにすると、<数字>.<数字>のような形式のバージョン文字列で無いとエラーになる。
デフォルトのLooseVersionであればもう少しユルい書式でバージョン比較できる。

Ansibleで実装されているversoin() / version_compare()についてはこちら
また、テスト(Test Pluginでなくソフトウェアテストの方のテスト)の内容についてはこちら

PythonVersion()は別途もうちょっと調べたいところ。。

含む (in)

Pythonin演算子と同じで、別途用意したリスト内に含まれる要素かどうかを、入力としてのリストの全要素に対して検査する。

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2
      - dns
    contain_list:
      - db1
      - web1
      - mail1

  tasks:
  - name: contain list
    debug:
      msg: '{{ hosts | select("in", contain_list) }}' # ['web1', 'db1']

inと似たものにcontainsもあるが、配列に対するselectフィルターに使用する場合、containsの引数に一致する要素をフィルタリング…しそうだけどそうはならず、配列要素を更にリストへ分解してその要素に含まれていればtrueという判定となっている。(書いててよくわからん)
まぁ一致する要素を抜き出したければeqを使えばいいので配列要素に対するcontainsはナンセンスのような気もする。(使い方の認識を誤ってる可能性もあるけど…)

下記の場合、"b"を含む文字列であればtrueになる。

  vars:
    contain_list:
      - db1
      - web1
      - mail1

  tasks:
  - name: contain list
    debug:
      msg:
      - '{{ contain_list | select("contains", "b") }}'  # ['db1', 'web1']

配列要素がiterableである必要があるため、数値の配列だと型エラーとなる。

動作する例としては以下のような感じ。(見やすいようにインラインリストにしている)

  vars:
    list_in_list:
      - [1,2,3]
      - [4,5,6]
      - [7,8,9]
  tasks:
  - name: contain list
    debug:
      msg:
      - '{{ list_in_list | select("contains", 5) }}'  # '[4,5,6]

辞書 (selectattr)

辞書の配列(辞書型の変数を要素に持つ配列)に対するselectattr()は、「辞書のkey名とTest名を指定することで、そのvalueがtrueかどうか」という動作をする。
指定のTestを行い、結果がtrueの場合に要素を返すという機能自体はselect()と同じ。

配列と若干異なる点としては「指定されたkeyが存在しない場合」に備えてdefinedを使ってガード処理を行ったり、配列の場合の動作がいまいちだったcontainsを使った「配列を要素として持つ場合に、その配列中に指定の要素があるか」などがある。

Test例は配列で一通りやってるので簡単に。。
以下のような、IPアドレスやパッケージ情報のホスト情報っぽい辞書型変数を要素に持つ配列を対象にselectattr()を使ったフィルタリングを行ってみる。

- hosts: localhost
  gather_facts: no

  vars:
    hosts:
      - name: web1
        addr: 192.168.2.10
        mask: 255.255.255.0
        pkg:
          - httpd
          - php
          - python
      - name: web2
        addr: 192.168.2.11
        mask: 255.255.255.0
        pkg:
          - httpd
          - ruby
          - python
      - addr: 192.168.2.0
        mask: 255.255.255.0
        pkg:
          - php
      - name: db1
        addr: 192.168.2.20
        mask: 255.255.255.0
        pkg:
          - mysql
          - postgres
          - php

  tasks:
  - name: selectattr filter sample
    debug:
      msg:
      - '{{ hosts | selectattr("name", "defined") }}'
      - '{{ hosts | selectattr("name", "defined") | selectattr("name", "eq", "web1") }}'
      - '{{ hosts | selectattr("name", "defined") | selectattr("name", "match", ".*1") }}'
      - '{{ hosts | selectattr("pkg", "contains", "php") }}'

キーが存在するか

'{{ hosts | selectattr("name", "defined") }}'

definedで、指定のkeyが存在する要素のみを返す。
前述hostsの例であれば、結果は以下の通り。

ok: [localhost] => 
  msg:
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.11
      mask: 255.255.255.0
      name: web2
      pkg:
      - httpd
      - ruby
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

キーnameの値との一致

'{{ hosts | selectattr("name", "defined") | selectattr("name", "eq", "web1") }}'

前述のdefinednameが存在するもののみをフィルタリングした結果に対し、更にnameの値がweb1のものを返す。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python

値に対する正規表現

'{{ hosts | selectattr("name", "defined") | selectattr("name", "match", ".*1") }}'

配列の場合と同様、matchsearchを使った正規表現も可能。
結果は以下の通り。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

含む (contains)

'{{ hosts | selectattr("pkg", "contains", "php") }}'

指定keyの値がリスト形式の場合、その配列要素に指定した文字列や数値を含む場合の要素を返す。
例の場合、valueに配列を持つkey名pkgに対して、その配列要素にphpを含む場合のホスト情報がフィルタリングされる。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.0
      mask: 255.255.255.0
      pkg:
      - php
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

含む (in)

(2021.07.15追記)
指定keyの値が文字列や数値などのスカラー値(ってAnsibleやPythonの文脈でいうの?)の場合で、比較対象として指定リストにその要素を含むか、という場合はinが使える。

addrチェック用に、次のようなリストを準備して、

    contain_list:
      - 192.168.2.20
      - 192.168.2.10
      - 192.168.0.89

以下の条件でフィルタを行うと、

'{{ hosts | selectattr("addr", "in", contain_list) }}'

このように、addrの値がcontain_listのリスト内に存在する値のみをフィルタリングできる。

ok: [localhost] => 
  msg:
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

以下過去の記事↓
入力が多重リストになってた。この比較はNGっぽい

前述のselect()の例で使用したinは、使い方が悪いのか空リストになった。

  - name: selectattr filter sample
    vars:
      contain_list:
        - php
        - mysql
    debug:
      msg:
      - '{{ hosts | selectattr("pkg", "in", contain_list) }}'

↑の内容のplayで実行すると以下の通り。

ok: [localhost] => 
  msg:
  - []

(おまけ) reject() / rejectattr()

条件に合致しない要素が欲しい場合は、逆の処理となるreject()rejectattr()を使用する。

見るべきドキュメント等

select() / selectattr() フィルター本体

ソースも確認すると理解が深まる。
実は中身はどちらも(さらに言うとreject()とrejectattr()も)select_or_reject()をコールしてるのよね。

Jinja2 Tests

まずJinja2 Templateのページ。
ここの「List of Builtin Tests」のリストにあるTestsが使用できる。
一致不一致や型チェックなどの基本的なTestはここで用意されている。

jinja.palletsprojects.com

ページ右下でバージョンを選べるが、これはAnsibleで使用しているPythonパッケージのJinja2のバージョンに合わせればよい。

Ansible Tests

Jinja2 TemplateのTest以外にAnsibleで内蔵されているTestもある。
Filterと同じくコレクション等のpluginリストにはないが、このページで一通り確認することができる。

docs.ansible.com

Testsのソースはcallbackやlookupなどのほかのpluginと同様に、testというディレクトリ以下にある。

まとめ

selectattr() + map()で指定キー配下の情報のみ抽出の例はよく見かけるけど、select()selectattr()についての情報があまりなかった気がするのでまとめてみた。
どちらのFilterもAnsibleではなくJinja2の機能で、使用する主要なTest(被演算子)もJinja2のドキュメントに記載があるのがポイント。
また、「select()selectattr()の引数で指定するのはFilterでなくTest」であることをおさえておけば、やりたいことを探しやすいと思う。(自分はここが曖昧だった)
※ 現状FilterとTestはCollection Indexから辿れず、AnsibleのFilterTestJinja2のドキュメントを見るしかないと思う。

select()selectattr()は、Perlだとgrep()相当でPowerShellだとWhere-Object相当かな。
今回は扱ってないmapは、Perlmap()相当でPowerShellだとForEach-Object相当のはず。

比較的単純なフィルタリングであればselect()+selectattr()で済ませて良いと思うが、抽出条件が複雑(深い位置にある値でフィルタリングして別要素の値を抜き出す、など)になってくると標準機能だけだとかえって分かりづらくなるため、json_query()なんかを使った方が良かったりするケースも出てくるので、要件によってやり方は考慮した方がよい。

(疑問 追記あり) builtin 以外の Filter / Test は…

Collection IndexからFilterとTestを辿れないということは、FilterTestに載っていない(特にbuiltin以外の)Filter / Testの情報はどこから知ればいいか不明…

例えばcommunity.generalにあるjc Filterとか。

zaki-hmkc.hatenablog.com

リポジトリのプラグインのディレクトリを見るしかないのかな。

2021.07追記:
community.generalコレクションのフィルタのドキュメントがいつのまにか追加されてた -> community.general Filter Guide — Ansible Documentation
コレクションによってはプラグインごとのドキュメントと別に独自のガイドが用意されるようになってる。

サンプルplaybook (on GitHub)


  • 2021.07.09 リンク切れを修正
  • 2021.07.28 「演算子」→「被演算子」に誤り修正

Ciscoラボ環境のCisco Modeling Labs Personal 2.1.2 をESXiに入れてみた

機会があってCisco Modeling Labsのライセンスを入手したので、手元のESXi環境で動かしてみた。
参考動画はこちら。

www.youtube.com

一旦はCML-Pの環境をセットアップして使えるところまで。
環境はESXi 6.7でRAM 64GB
作成されるCML-PのVMは、4vCPUs / RAM 8GB を要する。

購入して「My Account」を確認するとこの通り。

f:id:zaki-hmkc:20210213121353p:plain

「Download」を押下すると、製品のダウンロードページに遷移する。
本体の仮想マシンovaファイルとベアメタルデプロイ用イメージのisoファイル、あと以前から利用してる環境用のアップデート用のrpmファイルがある。(過去にはisoファイルは無かったらしい。任意の環境で構築できるようになったっぽい)

新規構築なのでovaとisoをダウンロード。
2021.02.13時点でバージョンは2.1.2。

f:id:zaki-hmkc:20210213145829p:plain

なお、ダウンロードには住所がフルで必要。
(購入のためのアカウント作成時には必須ではなかったが結局入力する必要がある)

f:id:zaki-hmkc:20210213122055p:plain

また、セットアップの最中に「refplat」のISOファイルが必要となるが、これはCML-Personal 2.1.2でなくCML-Personal 2.1.1 のリンクから入手できるので、これもダウンロードする。
(おそらく2.1.1 -> 2.1.2 の更新で変更が無かったのだろう)

f:id:zaki-hmkc:20210213145601p:plain

VM作成

ESXiなので今回はダウンロードしたovaファイルからVMを作成。
たぶんISOファイルを使えば任意の環境にセットアップできると思う。

ovaファイルからデプロイ

f:id:zaki-hmkc:20210213123249p:plain

f:id:zaki-hmkc:20210213123349p:plain

電源投入前に以下の設定を行う。

CPU設定

このホスト上で更にVMを管理するようで、CPU設定で「VTx / EPTを有効」にする設定が必要。
ESXi 6.7だとこの単語が見当たらないけど、ここ。
「ハードウェアのCPU、ソフトウェアのMMU」に設定。(もしかしたらMMUもハードウェアが正解かも。。)

f:id:zaki-hmkc:20210213140832p:plain

ストレージ

増やした方が良いらしいので16GBから32GBに変更。

ISO

ドキュメントとCML Personal 2.0の頃は「refplat」のISOダウンロードがあるが、現バージョンの2.1.2の画面からはダウンロードリンクがなかった。2.1.1にリンクがあるのでそこからダウンロードしてマウントする。

f:id:zaki-hmkc:20210213152357p:plain

以上で電源オン。

セットアップ

f:id:zaki-hmkc:20210213143608p:plain

間違ったISOファイルをマウントしていると以下の画面になるので正しいISOファイル(refplat / reference platformのISOファイル)をマウントする。

f:id:zaki-hmkc:20210213153120p:plain

アカウント作成

f:id:zaki-hmkc:20210213153150p:plain

f:id:zaki-hmkc:20210213153420p:plain

sysadminadminを作成。

ネットワーク設定

DHCP / Staticどちらか選択。
Staticの場合はIPアドレスを設定する。

f:id:zaki-hmkc:20210213154820p:plain

f:id:zaki-hmkc:20210213160315p:plain

準備が完了したらインストール処理開始。

f:id:zaki-hmkc:20210213160359p:plain

ブラウザアクセス

処理が終わったら設定したIPアドレスにブラウザでHTTPSアクセスする。(HTTPでアクセスしてもHTTPSへリダイレクトされる。)

f:id:zaki-hmkc:20210213160642p:plain

なお、9090/TCPへアクセスすれば、Cockpitにアクセスできる。
(Cockpitのログインアカウントはsysadminを使用する)

f:id:zaki-hmkc:20210214104535p:plain

また、コンソールの方も sysadmin でシェルログインできる。
(でもwheelグループが設定されてる割にはsudoの動きがよくわからん…?)

f:id:zaki-hmkc:20210213172337p:plain

ログイン

adminで設定したパスワードでログインする。

f:id:zaki-hmkc:20210213160914p:plain

画面の下部が赤いですね。

f:id:zaki-hmkc:20210213161149p:plain

ライセンスの設定が必要。

ライセンス

「Licensing」の部分のリンクか、画面右上のTools -> Licensingから。
「REGISTER...」ボタン押下。

f:id:zaki-hmkc:20210213161613p:plain

ライセンスはMy Accountのページで「2.x License」押下すると表示されるCopyボタンで取得できるので、その文字列を張り付けて「REGISTER」押下。

f:id:zaki-hmkc:20210214104422p:plain

しばらく待てばライセンスが設定・有効になる。

f:id:zaki-hmkc:20210213162630p:plain

画面下部のIssueの部分も「Status OK」に更新された。

f:id:zaki-hmkc:20210213162755p:plain

使ってみる

The Dashboard - Cisco Modeling Labs - Document - Cisco DevNet

ラボの作成

Creating a New Lab - Cisco Modeling Labs - Document - Cisco DevNet

画面右上の「ADD」から。

f:id:zaki-hmkc:20210213163919p:plain

これで一つのネットワーク環境が作成される。

f:id:zaki-hmkc:20210213163945p:plain

ノードの作成

かなり適当に以下のノードを配置してみる。

f:id:zaki-hmkc:20210213170444p:plain

ノード間を接続するには、ノードにカーソルを合わせると表示される接続マーク(?)を長押し、

f:id:zaki-hmkc:20210213170542p:plain

すると、接続したいノードまでドラッグすることで接続設定ができる。

f:id:zaki-hmkc:20210213170748p:plain

ノードへ接続すると、どのポートを使用するかのダイアログが表示されるのでポート(NIC)を選択する。

f:id:zaki-hmkc:20210213170825p:plain

f:id:zaki-hmkc:20210213170842p:plain

接続できました。
(このあとはこれを「電源オン」などすればいい模様)

参考情報

www.youtube.com

tekunabe.hatenablog.jp

[Ansible] to_datetime フィルタを使って文字列をdatetimeオブジェクトに変換

AnsibleでWed Feb 10 09:17:08 JST 2021という表記の文字列をdatetimeオブジェクトに変換するには、to_datetimeフィルターを使う。
使用例はフィルターのページの「Handling dates and times」に使い方が載っているのでここに書かれている通り。

内部実装はPythonのstrptime()が使用されているので、フォーマットの指定はPythonのドキュメントを見るとよい。

def to_datetime(string, format="%Y-%m-%d %H:%M:%S"):
    return datetime.datetime.strptime(string, format)

datetimeオブジェクトにすれば各メソッドが使用できるため、UNIX Epochにして比較するなど簡単になる。(←C/Perl育ちの秒で管理したい人間の発想)

環境

サンプルコード

    - name: to_datetime
      vars:
        str_sample: "2016-08-14 20:00:12"
        str_year: "2021"
        str_ymdhms: 2021.02.10 09-15-20
        str_jst: Wed Feb 10 09:17:08 JST 2021
        str_offset: Wed Feb 10 09:17:08 +0900 2021
      debug:
        msg:
          - "{{ str_sample | to_datetime }}"
          - "{{ str_year | to_datetime('%Y') }}"
          - "{{ str_ymdhms | to_datetime('%Y.%m.%d %H-%M-%S') }}"
          - "{{ str_jst | to_datetime('%a %b %d %H:%M:%S %Z %Y') }}"
          - "{{ str_offset | to_datetime('%a %b %d %H:%M:%S %z %Y') }}"
          - "{{ (str_jst | to_datetime('%a %b %d %H:%M:%S %Z %Y')).timestamp() }}"  # 型はfloat ('1612916228.0')
          - "{{ lookup('pipe', 'date +%s') }}"

実行結果

ok: [localhost] => 
  msg:
  - '2016-08-14 20:00:12'
  - '2021-01-01 00:00:00'
  - '2021-02-10 09:15:20'
  - '2021-02-10 09:17:08'
  - '2021-02-10 09:17:08+09:00'
  - '1612916228.0'
  - '1613054485'

書式について

デフォルトの書式

ドキュメントの以下コメント、

Default date format is %Y-%m-%d %H:%M:%S but you can pass your own format

および、実装もそうなってる通り、フィルター使用時に書式を何も指定しなければ%Y-%m-%d %H:%M:%Sとなる。
任意のフォーマット文字列を指定することも可能。

タイムゾーン

タイムゾーンについては実行環境のロケールPythonバージョンに依存するので注意。
(書式(抜粋)についても下記記事参照)

zaki-hmkc.hatenablog.com

  • %z

    • サンプルコードのstr_offsetにおける+0900の部分を解釈するが、Python 2系で動くAnsibleでは使用できない。
    • エラー:

      the field 'args' has an invalid value ({u'msg': [u"{{ str_offset | to_datetime('%a %b %d %H:%M:%S %z %Y') }}"]}), and could not be converted to an dict.The error was: 'z' is a bad directive in format '%a %b %d %H:%M:%S %z %Y'

  • %Z

    • サンプルコードのstr_jstにおけるJSTの部分を解釈するが、実行OSのタイムゾーン設定がJSTでないと使用できない。
    • エラー:

      the field 'args' has an invalid value ({u'msg': [u"{{ str_jst | to_datetime('%a %b %d %H:%M:%S %Z %Y') }}"]}), and could not be converted to an dict.The error was: time data 'Wed Feb 10 09:17:08 JST 2021' does not match format '%a %b %d %H:%M:%S %Z %Y'

[Python] datetime.strptime()を使った文字列からdatetimeオブジェクトへの変換とタイムゾーン

Pythonでは日付を表す文字列からdatetime型のオブジェクトへの変換にstrptime()を使用できる。
例えば'Thu Feb 11 17:01:34 2021'という文字列があった場合、'%a %b %d %H:%M:%S %Y'というフォーマット文字列を用意することでオブジェクトにできる。
なお、Ansibleの場合はto_datetimeフィルターでこの機能を使うこともできる。

docs.python.org

ここで、タイムゾーンの表記と処理で少しハマったので備忘録。

UTCオフセットによる表記 (例 +0900)

'Thu Feb 11 17:21:52 +0900 2021'

この表記の+0900の部分は、小文字の%zを使えば取得できる。

import datetime

date_str_tz = 'Thu Feb 11 17:21:52 +0900 2021'
format_str_tz = '%a %b %d %H:%M:%S %z %Y'
dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
print(dt_tz)
print(dt_tz.tzinfo)

このコードを実行すると、結果は以下の通りでawareなオブジェクトとなる。

2021-02-11 17:21:52+09:00
UTC+09:00

ただしこのコードは、Python 3.6.9 では動作するが、Python 2.7.18 では%zが対応しておらず以下のエラーで機能しない。

Traceback (most recent call last):
  File "strptime.py", line 18, in <module>
    dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
  File "/usr/local/lib/python2.7/_strptime.py", line 324, in _strptime
    (bad_directive, format))
ValueError: 'z' is a bad directive in format '%a %b %d %H:%M:%S %z %Y'

タイムゾーン名表記 (例 JST)

'Thu Feb 11 17:07:53 JST 2021'

この表記のJSTの部分は、大文字の%Zを使えば取得できる。

date_str_tz = 'Thu Feb 11 17:07:53 JST 2021'
format_str_tz = '%a %b %d %H:%M:%S %Z %Y'
dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
print(dt_tz)
print(dt_tz.tzinfo)  # None

実行すると以下の通り。
%zの場合と異なりtzinfoはNoneとなっている。

2021-02-11 17:07:53
None

ただしこの%Zを使ったタイムゾーン名の書式指定は、実行環境であるOSのタイムゾーン設定に依存しており、GMTUTC以外のタイムゾーンについてはOSの設定と合致していないとエラーになってしまう。
例えばpython:3Dockerイメージのように、OSのタイムゾーンが設定されておらずUTCのまま(シェル上でdate実行などで確認できる)の場合は、以下のようにエラーとなる。

Traceback (most recent call last):
  File "/tmp/strptime.py", line 12, in <module>
    dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 349, in _strptime
    raise ValueError("time data %r does not match format %r" %
ValueError: time data 'Thu Feb 11 17:07:53 JST 2021' does not match format '%a %b %d %H:%M:%S %Z %Y'

該当ドキュメントは以下の通り。(日本語版でも訳されてないけどなぜかどの言語もJSTと日本に住んでる人に向けた例文になっているw)

  • %Z

    In strftime(), %Z is replaced by an empty string if tzname() returns None; otherwise %Z is replaced by the returned value, which must be a string. strptime() only accepts certain values for %Z:

    1. any value in time.tzname for your machine's locale
    2. the hard-coded values UTC and GMT

    So someone living in Japan may have JST, UTC, and GMT as valid values, but probably not EST. It will raise ValueError for invalid values.

"Technical Detail" / datetime — Basic date and time types — Python 3.9.1 documentation https://docs.python.org/3/library/datetime.html#technical-detail

例えばESTというタイムゾーン名(東部標準時)の場合は、OSのタイムゾーン設定もESTになっている必要がある。

$ docker run --rm -v "$PWD":/mnt -e TZ=America/New_York -it python:3 bash
root@4e1469a8fd52:/# date
Thu Feb 11 08:09:23 EST 2021
root@4e1469a8fd52:/# python
Python 3.9.1 (default, Feb  9 2021, 07:42:03) 
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import datetime
>>> date_str_tz = 'Thu Feb 11 17:07:53 JST 2021'
>>> format_str_tz = '%a %b %d %H:%M:%S %Z %Y'
>>> dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 349, in _strptime
    raise ValueError("time data %r does not match format %r" %
ValueError: time data 'Thu Feb 11 17:07:53 JST 2021' does not match format '%a %b %d %H:%M:%S %Z %Y'
>>> print(dt_tz)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'dt_tz' is not defined

上記の通り、OS設定がESTになっていると,JSTの文字列は解析失敗している。
この状態からESTである文字列を処理すると以下の通り。

>>> date_str_tz = 'Thu Feb 11 17:07:53 EST 2021'
>>> dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
>>> print(dt_tz)
2021-02-11 17:07:53
>>> print(dt_tz.tzinfo)
None

この通り、ESTというタイムゾーン名を処理できている。

なお、タイムゾーン名がGMTあるいはUTCであれば、OSのタイムゾーンには関係せず、Python 2.7.18 でも動作確認。

$ docker run --rm -v "$PWD":/mnt -it python:2 bash
root@2bd09566c12f:/# python
Python 2.7.18 (default, Apr 20 2020, 19:27:10) 
[GCC 8.3.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import datetime
>>> date_str_tz = 'Thu Feb 11 17:07:53 UTC 2021'
>>> format_str_tz = '%a %b %d %H:%M:%S %Z %Y'
>>> dt_tz = datetime.datetime.strptime(date_str_tz, format_str_tz)
>>> dt_tz
datetime.datetime(2021, 2, 11, 17, 7, 53)
>>> dt_tz.tzinfo
>>> print(dt_tz.tzinfo)
None

Dockerのpythonコンテナでタイムゾーン設定するには

-e TZ=Asia/Tokyoを付与する。
※ これはpythonコンテナイメージ(baseがdebian)の場合。CentOS系も同様。ただしAlpineはまた別

[zaki@cloud-dev ~]$ docker run --rm -v "$PWD":/mnt -e TZ=Asia/Tokyo -it python:3 bash
root@d8719dc21019:/# date
Thu Feb 11 22:47:17 JST 2021

(おまけ) strptime()のフォーマット文字列抜粋

頻出

指定子 意味
%Y 西暦 (4桁)
%m 月 (01オリジン)
%d
%H
%M
%S

マイナー(個人の感想)な表記

指定子 意味
%a 曜日の英語表記(Mon.とかfriとか)
%b 月の英語表記(Febとかmayとか)
%f マイクロ秒(秒の小数点未満6桁)
%I 12時間表記のhour (↓とセットで使う)
%p AM/PM (↑とセットで使う)
%Z タイムゾーン名 (UTCとかGMTとかJSTとか。本記事参照)
%z UTCオフセット

ドキュメントには「ロケールの曜日名」とか書かれているが、試した限りja_JP.UTF-8設定の環境でも「」とかは認識しなかった。

参考情報

docs.python.org

qiita.com

dev.classmethod.jp

note.nkmk.me

qiita.com

[Ansible] jc parserのFilterを使った構造化データ化 (community.generalのフィルタ)

jc parserをAnsibleで使ってみる。

github.com

2021.07.06追記: community.generalコレクションのフィルタのドキュメントがいつのまにか追加されてた -> community.general Filter Guide — Ansible Documentation

jc parserについて

jc parserは、Ansibleで使用できるFilterの一つ。
jcで用意されているparserを指定することで、様々なLinuxコマンドの出力などを自動でparseし、構造化データに変換することができる。

Ansible 2.10だとcommunity.general collectionにFilterとして含まれる。

Community.General Plugin Index

ただ、Ansible DocumentationサイトではFilterのリファレンス(Filterのリスト)が無いため、2021.02.09時点でドキュメントはちょっと見当たらず。。 → 2021.07.06 現在、フィルターガイドというドキュメントが(6月頃に?)新設されていて、community.general コレクションで提供されるフィルタープラグインはここにまとめられている。

docs.ansible.com

ソースはplugins/filter/jc.pyで確認でき、コメントを見ればだいたい使い方はわかるはず。

また、jcそのものについては、jc parser本体のリポジトリに詳細がある

github.com

使えるparserの種類

これはjcのGitHubのリポジトリのREADMEのParsersの章を見るのが早い。
ここの一覧はCLIツールとしてのjcで使用するparserのリストになっているが、Ansibleでこれらのparser名をFilterの引数に指定して使う。

簡単な使い方

環境

確認した環境は以下の通り。

  • Ansible: 2.10.5
  • community.general: 1.3.5
  • jc: 1.14.1

準備

jcパッケージが必要なのでpipでインストールしておく。

$ pip install jc

ps

例えばps auxの結果を構造化データに変換したい場合。

    - name: jc parser
      vars:
        result: "{{ lookup('pipe', 'ps aux') }}"
      debug:
        msg: "{{ result | community.general.jc('ps') }}"

この内容のplaybookを実行すると、実行結果は以下の通り。

TASK [jc parser] **************************************************************
ok: [localhost] => {
    "msg": [
        {
            "command": "/usr/lib/systemd/systemd --switched-root --system --deserialize 22",
            "cpu_percent": 0.0,
            "mem_percent": 0.0,
            "pid": 1,
            "rss": 6048,
            "start": "2020",
            "stat": "Ss",
            "time": "9:40",
            "tty": null,
            "user": "root",
            "vsz": 194036
        },
        {
            "command": "[kthreadd]",
            "cpu_percent": 0.0,
            "mem_percent": 0.0,
            "pid": 2,
            "rss": 0,
            "start": "2020",
            "stat": "S",
            "time": "0:00",
            "tty": null,
            "user": "root",
            "vsz": 0
        },

        [...]

ちなみに、ps -efの結果でもちゃんとparseされる。
ただし、ps auxf見れる階層情報についてはparse対象外のため、プロセス名(command)の部分に階層表示のための|\\_が入り込む。

free

    - name: jc parser free
      vars:
        free: "{{ lookup('pipe', 'free') }}"
      debug:
        msg: "{{ free | community.general.jc('free') }}"
TASK [jc parser free] *********************************************************
ok: [localhost] => {
    "msg": [
        {
            "available": 3237832,
            "buff_cache": 3034204,
            "free": 528604,
            "shared": 27256,
            "total": 7990060,
            "type": "Mem",
            "used": 4427252
        },
        {
            "free": 3655420,
            "total": 3670012,
            "type": "Swap",
            "used": 14592
        }
    ]
}

freeは人の目に優しいhuman optionを付けてると(おそらく数値のみを拾ってるっぽくて)parseできない。
(オプション確認してhumanhだとわかったけど、ずっとhugehだと思ってた…)

$ free -h
              total        used        free      shared  buff/cache   available
Mem:           7.6G        4.2G        584M         26M        2.9G        3.2G
Swap:          3.5G         14M        3.5G

こうなる状態で、以下のplaybookを実行。

    - name: jc parser free
      vars:
        free: "{{ lookup('pipe', 'free -h') }}"
      debug:
        msg: "{{ free | community.general.jc('free') }}"

結果は以下の通り。

TASK [jc parser free] *********************************************************
ok: [localhost] => {
    "msg": [
        {
            "available": null,
            "buff_cache": null,
            "free": null,
            "shared": null,
            "total": null,
            "type": "Mem",
            "used": null
        },
        {
            "free": null,
            "total": null,
            "type": "Swap",
            "used": null
        }
    ]
}

他にもiptablesrouteなど、OSの設定系を構造化データとして取得することで加工・再利用しやすくなると思う。
ssはあるのに、ipは一覧に無さげなのは何故だろう。(なのでTTP使ったparseを試した)

2021.07.03 追記:

なるほど、ip -j afrom_jsonフィルタに突っ込んだりすれば簡単にオブジェクトに出来るから、jc parserが無くても大丈夫ですね。情報ありがとうございます!

ini

個人的に面白いと思ったのがiniのフィルタ。

[ansible]
command = ansible
filetype = yaml
name = playbook

[kubernetes]
command = kubectl
filetype = yaml
name = manifest

こんな形式のテキストデータに対して、

    - name: jc parser ini
      vars:
        ini: "{{ lookup('file', 'jc-sample.ini') }}"
      debug:
        msg: "{{ ini | community.general.jc('ini') }}"

iniを指定してフィルタをかますと、

ok: [localhost] => {
    "msg": {
        "ansible": {
            "command": "ansible",
            "filetype": "yaml",
            "name": "playbook"
        },
        "kubernetes": {
            "command": "kubectl",
            "filetype": "yaml",
            "name": "manifest"
        }
    }
}

このように、セクション名をキーにとしたディクショナリ型に変換してくれる。

kv (key / value)

セクション名の無い単なるkey=value形式のファイルの場合。

command = ansible
filetype = yaml
name = playbook

kvというparserもあるが、実はiniでもparseできる。
デリミタは=でなく:でもOK

command: ansible
filetype: yaml
name: playbook

ソース確認するとkvの中身は実はiniをコールしているだけ。

サンプル

github.com

まとめ

jc parserに対応している書式のテキストデータを扱う場合は簡単に処理できるのでオススメです。