見出し画像

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. ~/.ansible/plugins/modules
2. <playbookのディレクトリ>/library/modules

1.のほうに配置しておくと、すべてのPlaybookからそのモジュールが使用できることになりますので、今回はこちらのディレクトリに配置することにします。

そして、ファイル名ですが、Powershellなので "<モジュール名>.ps1" となります。今回はモジュール名を"win_driverstore"としたいと思いますので、モジュールファイル名は "win_driverstore.ps1"となります。

さてさて、モジュールの中身の作成方法ですが、まずは下のドキュメントを読みます。これは、Windows用のモジュールを作成するために必要な情報を集めたページで、なかなかの量があります。

Windows用モジュール開発の掟(?)
このページには、コーディングルールのようなものもまとめられているので、重要な点を抜粋します(筆者による適当な意訳のため、詳しく知りたい場合は原文を読んでください)。

・(何か文字列を出力したい場合は)Write-Host/Debug等は使わずに$module.Resultに書き出すこと
・実行結果をFailにする場合は$module.FailJson("failure message here")を呼び出すこと
・チェックモードを実装すること
・大きなブロックのtry/catchはせずに、小さなブロックで、Exceptionを指定してキャッチするようにすること
・どうしても必要な場合を除いてPSCustomObjectsは使わないこと
・./lib/ansible/module_utils/powershell/にユーティリティ関数があるから、(同じような関数を自分で書かずに)これらを積極的に使うこと
・可能であればexeファイルを実行はせずPowershellのコマンドレットを使うこと
・コマンドレットは省略形ではなくフルネームで使う(例えば"rm"ではなく"Remove-Item"を使う)
などなど

ややルールが多くて堅苦しいイメージがありますが、個人で使うモジュールの開発であれば、すべてのルールに厳密に従う必要もないでしょう。しかし、最終的に公開しようと考えている場合は、しっかり守っていく必要があります。

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つのパラメーターを定義することにします。

inf_path: ドライバーパッケージ内に存在するinfファイルへのパス
subdirectories: ドライバーパッケージが参照するディレクトリ(オプション)
state: ドライバーパッケージが追加された状態か、存在しない状態か(present/absent)
install: ドライバーパッケージを追加する際にpnputl.exeの/installオプションを使用するかどうか(yes/no、デフォルトはyes)
force: ドライバーパッケージを削除する際にpnputl.exeの/forceオプションを使用するかどうか(yes/no、デフォルトはno)

これらのパラメーターの定義と読み込みは、以下のようなコードで行います。

$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を実行してみます。

画像1

結果はこのように成功し、ドライバーパッケージがC:\Windows\System32\DriverStore\FileRepositoryに追加されました。

画像2

デバイスマネージャーから”Update Driver"(ドライバーの更新)メニューを選んでみると、下のように今回追加したVersion 2.7.21.14.6140のドライバーが選択肢に表れていることからも、ドライバーパッケージが追加されたことが確認できます。

画像3

※なお、この時点ではドライバーパッケージがDriver Storeに追加されただけなので、このドライバーはロードされません。Playbookで"install: yes"として実行すると、追加したドライバーがインストールされて、新しいディスプレイドライバーが起動します。

ついでに、同じPlaybookをもう1度実行してみましょう。結果は下のように"OK"となり、(少なくともこのアクションについては)冪等性があることが確認できます。

画像4

では、逆に削除してみましょう。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

結果は、下のようにドライバーパッケージが削除できました。

画像5

ということで、今回の目的を果たすモジュールが作成できました。

この自作モジュールを使うことで、複数の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. コメントの追加やコードクオリティの向上
コーディングガイドラインに則って書かれていることを確認して、必要十分なコメントを追加します。

このあたりまで作りこめれば、広い範囲で使える便利なモジュールになるのではないかなと思います。需要があるようならチャレンジしてみようかな、と思っていますが、どうでしょうか。

もしそんなモジュール欲しい、という方がいらっしゃれば💓ボタンお願いします!

――――――――――――――――――――――――――――――――――

執筆者プロフィール:水谷 裕一
大手外資系IT企業で15年間テストエンジニアとして、多数のプロジェクトでテストの自動化作業を経験。その後画像処理系ベンチャーを経てSHIFTに入社。
SHIFTでは、テストの自動化案件を2件こなした後、株式会社リアルグローブ・オートメーティッド(RGA)にPMとして出向中。RGAでは主にAnsibleに関する案件をプレーイングマネジャーとして担当している。

公式noteお問合せ画像

お問合せはお気軽に
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/