Ansibleのモジュールを開発してみた
こんにちは。自動化エンジニアの水谷です。
相変わらず時間があるときに、いろいろ調べながら手を動かしてAnsibleの勉強をしているのですが、今回はついにモジュールの開発にチャレンジしてみましたので、そこで経験したことを書いておきたいと思います。
まずはネタ探し
Ansibleは、操作対象となるマシンに対してアクションを行うモジュールが、デフォルトで4,000個以上用意されていて、現在もコミュニティや企業がモジュールの追加、改良を行っています。このため、既存のモジュールだけでPlaybookが作成できることがほとんどなのですが、以前からよりAnsibleを知るためにも、1つでいいので簡単なモジュールを作ってみようと思っていました。
モジュールを作ると決めたとはいえ、Linux系OS用の新しいモジュールを作ろうとしても、すでに十分に充実していて「作りつくされている」とも思える状況。まったく、新しいモジュールのネタが思いつきません。また、そもそもLinux系OSにそれほど詳しいわけでもないので、”これがあったら便利なんだよな”というアイディアも持ち合わせていない状況です。
そこで、Windowsに目を向けました。AnsibleはWindowsを操作するモジュールもたくさんあるのですが、Linux系OS向けのものに比べて数はまだ少ない状況です。私はWindowsの使用歴が長く、それなりにディープに使ってきましたので、それならばとWindows用モジュールを作ってみることにしました。モジュールの開発も、慣れ親しんだPowershellで書ける点も安心感があります(というか、Pythonがそれほど得意ではないだけの話だったりしますがw)。
2つほどWindows用のモジュールのネタが思い浮かんだのですが、今回はそのうちの1つ、デバイスドライバーのインストールを行うモジュール(正確には、ドライバーパッケージをWindowsの"Driver Store"に追加するモジュール)を作ってみることにします。
ドライバーの追加方法
ご存じの方もいらっしゃると思いますが、一般的にWindowsのデバイスドライバーは、infファイルやcatファイルを含む"ドライバーパッケージ"の形で提供されます。これをDriver Storeに追加しておくと、(パッケージに含まれる)infファイルに記述したPnP IDにマッチしたデバイスが存在した場合に(あるは列挙されたときに)、このパッケージがロードされて、実行されます(実際にはもう少し複雑ですが)。
このDriver Storeへのドライバーパッケージの追加は、主に2つの方法で行うことができます。1つは"pnputil.exe"というWindowsがもつコマンドラインツールを使う方法で、”/add-driver"オプションを指定すればドライバーパッケージをDriver Storeに追加してくれます。もう1つはイメージ管理ツールであるdism.exeで、こちらも、"/Online /Add-Driver"オプションを使えば同等のことができます。どちらを使ってもよかったのですが、今回はよりシンプルなpnputil.exeを使うことにします。
モジュール作成の準備
さて、作成方針が決まったところで、いよいよモジュールを作成、と行きたいところですが、少し下調べと準備が必要です。
まず、カスタムモジュールを配置する場所ですが、調べてみるとこれは下の2ヶ所となっているようです。
1.のほうに配置しておくと、すべてのPlaybookからそのモジュールが使用できることになりますので、今回はこちらのディレクトリに配置することにします。
そして、ファイル名ですが、Powershellなので "<モジュール名>.ps1" となります。今回はモジュール名を"win_driverstore"としたいと思いますので、モジュールファイル名は "win_driverstore.ps1"となります。
さてさて、モジュールの中身の作成方法ですが、まずは下のドキュメントを読みます。これは、Windows用のモジュールを作成するために必要な情報を集めたページで、なかなかの量があります。
Windows用モジュール開発の掟(?)
このページには、コーディングルールのようなものもまとめられているので、重要な点を抜粋します(筆者による適当な意訳のため、詳しく知りたい場合は原文を読んでください)。
ややルールが多くて堅苦しいイメージがありますが、個人で使うモジュールの開発であれば、すべてのルールに厳密に従う必要もないでしょう。しかし、最終的に公開しようと考えている場合は、しっかり守っていく必要があります。
win_environment.ps1を参考にする
そして、重要なヒントとして、"win_environment.ps1"が、チェックモードや"diff"機能にも対応して、引数に問題がある場合は警告メッセージを表示するなど、しっかりと実装された例なので、これを参考にするように書かれています。
Ansibleに同梱されているWindows用モジュールは、/usr/lib/pythonx.x/dist-packages/ansible/modules/windows にありますので、このwin_environment.ps1をベースにして実装していくことにします。
とはいえ、今回は最初からたくさんの機能を実装せずに、Driver Storeに一度に1つのドライバーパッケージを追加することと、1つのドライバーパッケージが削除できるだけのシンプルなモジュールにしたいと思います。ただし、冪等性は重要ですので、これは担保するように作ることにします。
モジュールを作ってみる
さて、コーディングです。まずはパラメーターを受け取るところから。
パラメーターの受け取り
Ansibleのモジュールは、任意の数のパラメーターを定義して、受け取ることができるのですが、今回のモジュールは以下の5つのパラメーターを定義することにします。
これらのパラメーターの定義と読み込みは、以下のようなコードで行います。
$spec = @{
options = @{
inf_path = @{ type = "path" }
subdirectories = @{ type = "str"; default = "" }
state = @{ type = "str"; choices = "absent", "present"; default = "present" }
install = @{ type = "bool"; default = $true }
force = @{ type = "bool"; default = $false }
}
supports_check_mode = $true
}
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
$inf_path = $module.Params.inf_path
$subdirectories = $module.Params.subdirectories
$state = $module.Params.state
$install = $module.Params.install
$force = $module.Params.force
$check_mode = $module.Params._ansible_check_mode
また、最終的にモジュールが返す結果を下のように用意しておきました。
$result = @{
changed = $false;
command = "";
stdout = ""
}
ここに値を入れていき、最後に$module.Result.values = $resultとして、モジュールの戻り値とする予定です。
インストール済みドライバーパッケージかどうかの判定
続いて、ドライバーパッケージのインストールの部分を書いていきたいのですが、冪等性を考えると、先にそのドライバーパッケージがすでにインストールされているかどうかの判定を行うコードを用意したほうがよさそうです。
ということで、GetInstalledInf()という名前の関数を作りました。Windowsのデバイスドライバーは、インストール時にinfファイルが"oemnn.inf"(nは数字)にリネームされて、%windir%\INFディレクトリにコピーされることを利用ます。この関数は、引数に与えられたinfファイルが既に%windir%\INFディレクトリにコピーされているかどうかを判断し、コピーされていればそのファイル名(oemnn.inf)を返します。
引数のinfとoemnn.infがマッチするかどうかは、ファイルのチェックサム(ハッシュ)を比較することで行えばよいので、以下のようなコードにしました。
Function GetInstalledInf($inf) {
$checksum = Get-FileChecksum -path $inf
$result.inf_checksum = $checksum
$all_oeminfs = Get-ChildItem (Join-Path ([Environment]::GetFolderPath("Windows")) "INF") | Where-Object name -match "OEM\d+.inf"
Foreach ($oeminf in $all_oeminfs) {
$oeminfchecksum = Get-FileChecksum -path $oeminf.FullName
if ($checksum -eq $oeminfchecksum) {
$result.installed_inf = $oeminf.FullName
return $oeminf.FullName
}
}
$result.foundeominf = ""
return ""
}
ここで使用しているGet-FileChecksumは、Ansibleによって用意されたユーティリティ関数の1つで、デフォルトではSHA1のファイルハッシュを返してくれます。これと、%windir%\INF内のoemnn.infでファイルハッシュがマッチするものがあれば、そのファイル名を返し、なければ""(ヌル文字列)を返します。
ドライバーパッケージを追加するコード
では、ドライバーパッケージの追加部分を見ていきましょう。
$oeminf = GetInstalledInf $inf_path
if ($state -eq "present") {
if ($oeminf -eq "") {
$result.action = "adding driver to driver store"
$command = "pnputil.exe /add-driver `"$inf_path`""
if ($subdirectories.length -gt 0) {
$command += " /subdirs `"$subdirectories`""
}
if ($install) {
$command += " /install"
}
$result.command = $command
if (-not $check_mode) {
$stdout = Invoke-Expression $command
$result.stdout = $stdout
if ([regex]::matches($stdout, "Failed to add").length -gt 0)
{
$module.Result.values = $result
$module.FailJson("Failed to add driver package")
}
}
$result.changed = $true
}
}
$oeminfが""の場合(そのドライバーパッケージがインストール済みでない場合)のみ、Invoke-Expressionでpnputil.exeを実行しています。この時の引数は、$subdirectoriesがあれば、"/subdirs"オプションを、また、$installが$trueであれば、"/install"オプションを追加しています(Driver Storeに追加後、インストール作業も行う)。
そして、標準出力を$result.stdoutに格納しておき、これをモジュールの戻り値の1つとします。また、この標準出力の中に"Failed to add"の文字列があれば、追加が失敗したことを意味しますので、$module.FailJson()を呼んで、実行結果を”FAIL”としています。そうでなければ、$result.changedを$trueにして変更があったことを記します。
ドライバーパッケージを削除するコード
続いて$stateが"present"ではなかった場合、つまり"absent"の場合の処理を書きます。こちらは逆に$oeminfが""ではない場合のみpnputil.exeを実行することになります。
else {
if ($oeminf -ne "") {
$result.action = "removing driver from driver store"
$command = "pnputil.exe /delete-driver `"$oeminf`""
if ($force) {
$command += " /force"
}
$result.command = $command
if (-not $check_mode) {
$stdout = Invoke-Expression $command
$result.stdout = $stdout
if ([regex]::matches($stdout, "Failed to delete driver package").length -gt 0)
{
$module.Result.values = $result
$module.FailJson("Failed to delete driver package")
}
}
$result.changed = $true
}
}
なお、$forceが$trueの場合は"/force"オプションを追加してpnputil.exeを実行しています。
あとは最後に下の1行を追加して完成です。
$module.Result.values = $result
$module.ExitJson()
念のためコード全体を貼っておきます。
#!powershell
# Copyright: (c) 2021, Yuichi Mizutani
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
#AnsibleRequires -CSharpUtil Ansible.Basic
#Requires -Module Ansible.ModuleUtils.Legacy
$spec = @{
options = @{
inf_path = @{ type = "path" }
subdirectories = @{ type = "str"; default = "" }
state = @{ type = "str"; choices = "absent", "present"; default = "present" }
install = @{ type = "bool"; default = $true }
force = @{ type = "bool"; default = $false }
}
supports_check_mode = $true
}
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
# $module.Result.values = @{}
$inf_path = $module.Params.inf_path
$subdirectories = $module.Params.subdirectories
$state = $module.Params.state
$install = $module.Params.install
$force = $module.Params.force
$check_mode = $module.Params._ansible_check_mode
$result = @{
changed = $false;
command = "";
stdout = ""
}
Function GetInstalledInf($inf) {
$checksum = Get-FileChecksum -path $inf
$result.inf_checksum = $checksum
$all_oeminfs = Get-ChildItem (Join-Path ([Environment]::GetFolderPath("Windows")) "INF") | Where-Object name -match "OEM\d+.inf"
Foreach ($oeminf in $all_oeminfs) {
$oeminfchecksum = Get-FileChecksum -path $oeminf.FullName
if ($checksum -eq $oeminfchecksum) {
$result.installed_inf = $oeminf.FullName
return $oeminf.FullName
}
}
$result.foundeominf = ""
return ""
}
if (-not (Test-Path -LiteralPath $inf_path)) {
Fail-Json -obj $result -message "specified inf file '$inf_path' does not exist."
}
$oeminf = GetInstalledInf $inf_path
if ($state -eq "present") {
if ($oeminf -eq "") {
$result.action = "adding driver to driver store"
$command = "pnputil.exe /add-driver `"$inf_path`""
if ($subdirectories.length -gt 0) {
$command += " /subdirs `"$subdirectories`""
}
if ($install) {
$command += " /install"
}
$result.command = $command
if (-not $check_mode) {
$stdout = Invoke-Expression $command
$result.stdout = $stdout
if ([regex]::matches($stdout, "Failed to add").length -gt 0)
{
$module.Result.values = $result
$module.FailJson("Failed to add driver package")
}
}
$result.changed = $true
}
}
else {
if ($oeminf -ne "") {
$result.action = "removing driver from driver store"
$command = "pnputil.exe /delete-driver `"$oeminf`""
if ($force) {
$command += " /force"
}
$result.command = $command
if (-not $check_mode) {
$stdout = Invoke-Expression $command
$result.stdout = $stdout
if ([regex]::matches($stdout, "Failed to delete driver package").length -gt 0)
{
$module.Result.values = $result
$module.FailJson("Failed to delete driver package")
}
}
else {
$result.changed = $true
}
}
}
$module.Result.values = $result
$module.ExitJson()
モジュールを実行してみる
このモジュールを実行するために、以下のようなPlaybookを用意しました。
---
- hosts: windows
gather_facts: no
tasks:
- name: add NVIDA display driver to driver store
win_driverstore:
inf_path: 'C:\tmp\NVIDIA\DisplayDriver\461.40\Win10-DCH_64\International\Display.Driver\nv_dispi.inf'
install: no
force: yes
state: present
register: result
これは、(ホスト上の)C:\tmp\NVIDIAに置いてある、ビデオドライバーをDriver Storeに追加するものになります(ドライバー自体はNVIDIAのサイトからダウンロードしてドライバーパッケージのみ抽出しています)。
デバッグ情報を見るために "-vvv" オプションつけてPlaybookを実行してみます。
結果はこのように成功し、ドライバーパッケージがC:\Windows\System32\DriverStore\FileRepositoryに追加されました。
デバイスマネージャーから”Update Driver"(ドライバーの更新)メニューを選んでみると、下のように今回追加したVersion 2.7.21.14.6140のドライバーが選択肢に表れていることからも、ドライバーパッケージが追加されたことが確認できます。
※なお、この時点ではドライバーパッケージがDriver Storeに追加されただけなので、このドライバーはロードされません。Playbookで"install: yes"として実行すると、追加したドライバーがインストールされて、新しいディスプレイドライバーが起動します。
ついでに、同じPlaybookをもう1度実行してみましょう。結果は下のように"OK"となり、(少なくともこのアクションについては)冪等性があることが確認できます。
では、逆に削除してみましょう。Playbookで"state: present"としていた行を"state: absent"に変えるだけですので、このようなPlaybookになります。
---
- hosts: windows
gather_facts: no
tasks:
- name: remove a driver
win_driverstore:
inf_path: 'C:\tmp\NVIDIA\DisplayDriver\461.40\Win10-DCH_64\International\Display.Driver\nv_dispi.inf'
state: absent
register: result
結果は、下のようにドライバーパッケージが削除できました。
ということで、今回の目的を果たすモジュールが作成できました。
この自作モジュールを使うことで、複数のWindows PCに最新のドライバーを自動配布、およびインストールができることになります! ま、自分はNVIDIAのディスプレイカードを搭載したマシンを1台しか持っていないので無意味ですがw
追加が必要な項目
とりあえず、無事動くモジュールができたので今回の目標は達成できたのですが、まだまだ公開して自分以外の人に使っていただくようなものではありません。追加していくべき項目を挙げておこうと思います。
1. ドライバーパッケージのコントロールノードからのコピー
win_copyモジュールのように、ドライバーパッケージをAnsibleが動いているマシン上に置いておき、これをホストマシンにコピーして、パッケージを追加するようにしたいところです。が、調べてみたところ、これがかなり手間がかかりそうです。このコピー作業は、モジュールではなく、action plugin(/usr/lib/pythonn.n/dist-packages/ansible/plugins/action/にあるPythonコード)で行っているようです。そこで、win_copyのaction pluginコードを真似してwin_driverstoreのaction pluginをPythonで書く必要があります。また、win_copyでもやっているように、転送時間を短縮するためにドライバーパッケージを(action plugin内で)zipファイルにして転送し、モジュール内で展開する動作も実装すべきなので、これまた実装に手間がかかりそうです。
2. ドキュメント作成
ansible-docコマンドで表示されるドキュメントは、Pythonで書いたモジュール内に書きますので、(action pluginとは別に)win_driverstore.pyを作ってモジュールと同じディレクトリに配置し、そこでパラメーターの使い方など詳細なドキュメントを記述します(当然ながら英語で書く必要があります)。
3. 複数ドライバーパッケージのインストールへの対応
ドライバーパッケージ内に複数のinfがある場合などで、それぞれをDriver Storeに追加することを可能にするように拡張したほうが使い勝手が向上しますので、これにも対応したいところです。
4. Dismとオフラインイメージへの対応
pnputil.exeではなく、dism.exeへの対応を行いたいところです。なぜなら、dism.exeならオフラインイメージへのドライバーパッケージ追加も可能になるからです(優先順位は低め?)。
5. diff機能のサポート
モジュール実行前と実行後で何が変わったのかを分かりやすい形で返す仕組みのようで、これに対応するべきかと思われます。
6. パラメーターのエラーチェックの追加
一部のパラメーターのチェックはすでに入れていますが、もう少し充実させる必要があります。
7. コメントの追加やコードクオリティの向上
コーディングガイドラインに則って書かれていることを確認して、必要十分なコメントを追加します。
このあたりまで作りこめれば、広い範囲で使える便利なモジュールになるのではないかなと思います。需要があるようならチャレンジしてみようかな、と思っていますが、どうでしょうか。
もしそんなモジュール欲しい、という方がいらっしゃれば💓ボタンお願いします!
――――――――――――――――――――――――――――――――――
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/