ひよこでも書けるWindowsモジュール開発ことはじめ(前編)

この記事は、「Ansible Advent Calendar 2021」の24日目の記事です。

レッドハット Ansibleサポートチームの八木澤(@hiyoko_taisa)と申します。世間はクリスマスムード一色ですが、皆様いかがお過ごしでしょうか。Ansibleサポートチームは、クリスマス・イブで浮かれる世間を尻目に、Ansible世界の平和を守るべく今日も奔走しておりました。

さて、今回の記事では、「Windowsモジュールの開発」について取り上げます。巷ではLinux用のモジュール開発についての情報は多く存在しますが、Windowsのモジュールについて日本語で書かれた情報はほとんどありません。私は以前、「win_zip」というWindows上でファイルをZip圧縮するモジュールを作成したことがあり、レビュアーにコードを魔改造されたりCIが通らずに泣きながら何度も修正したりして、なんとか「community.windows」コレクション内の1モジュールとしてリリースすることができました。今回はAnsibleコミュニティのWindows部長(自称)として、そこで得た知見を皆様にフィードバックしていければと思います。今回の記事はいろいろと書きたいことを詰め込みすぎて、想定よりはるかに長くなってしまいましたので、今回は前編として、年明けを目処に後編の方も公開させていただきます。ご了承ください。

また、この記事を読んで「Windows用のモジュール開発をしたい!」と思い立った奇特な方は、これらの公式ドキュメントにまず目を通してから実際に作業されることをおすすめします。

Developer Guide: https://docs.ansible.com/ansible/latest/dev_guide/index.html

Windows module development walkthrough: https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general_windows.html

モジュール開発に至った経緯

そもそもAnsibleには「win_unzip」というzipファイルを展開するモジュールは存在していましたが、圧縮するモジュールはありませんでした。何度か同じようにzip圧縮を行うモジュールが提案されたことはあったようですが、実際に導入されるには至らなかったようです。win_zip実装を望むIssueもあり、「ならモジュール開発の経験を積むためにも自分で書いてみよう!」というのがそもそもの開発の動機です。

モジュールを開発する際には、別モジュールの機能として実装されていないか、作業中のものがないかなど最初にAnsibleのGitHubリポジトリを探してみましょう。私の場合は現在進行中のものはないようでしたので、すぐさま開発に着手することにしました。

なお、Windows用のモジュールが存在するコレクションは、Red Hatが公式にサポートするコアとなるモジュール群を含む「ansible.windows」と、コミュニティによって開発・メンテナンスされる「community.windows」の2種類が存在します。今回は、「community.windows」向けにリリースするモジュールの開発の話となります。

Windowsモジュールの構造

Windows用のモジュールは、Linux用のそれと若干構造が異なります。多くのWindowsモジュールは、以下の2つのファイルによって構成されます。それぞれ「plugins/modules/」配下に配置されます。

<モジュール名>.py

このPythonスクリプトは、主にドキュメントの生成のために利用されます。LinuxモジュールではこのPythonスクリプトがモジュールそのものですが、Windows上ではPythonを実行できないため、後述するPowerShellが利用されます。

以下は、私が記述した「win_zip.py」の例です。

DOCUMENTATION = r'''
---
module: win_zip
short_description: Compress file or directory as zip archive on the Windows node
description:
- Compress file or directory as zip archive.
- For non-Windows targets, use the M(ansible.builtin.archive) module instead.
notes:
- The filenames in the zip are encoded using UTF-8.
requirements:
- .NET Framework 4.5 or later
options:
  src:
    description:
      - File or directory path to be zipped (provide absolute path on the target node).
      - When a directory path the directory is zipped as the root entry in the archive.
      - Specify C(\*) to the end of I(src) to zip the contents of the directory and not the directory itself.
    type: str
    required: yes
  dest:
    description:
      - Destination path of zip file (provide absolute path of zip file on the target node).
    type: path
    required: yes
seealso:
- module: ansible.builtin.archive
author:
- Kento Yagisawa (@hiyoko_taisa)
'''

EXAMPLES = r'''
- name: Compress a file
  community.windows.win_zip:
    src: C:\Users\hiyoko\log.txt
    dest: C:\Users\hiyoko\log.zip
- name: Compress a directory as the root of the archive
  community.windows.win_zip:
    src: C:\Users\hiyoko\log
    dest: C:\Users\hiyoko\log.zip
- name: Compress the directories contents
  community.windows.win_zip:
    src: C:\Users\hiyoko\log\*
    dest: C:\Users\hiyoko\log.zip
'''

DOCUMENTATION部分にモジュール概要などを、EXAMPLEに使用例を定義します。ここの記述についてはLinux用のモジュールと大きな違いはありません。

このファイルに記載された内容を元に、公式ドキュメントのモジュールのページなどが生成されますので、モジュールの実際の中身とドキュメントが食い違わないように注意する必要があります。特に開発初期の段階でドキュメント部分を作成すると、「こんな機能つくろう」と思ってドキュメントに書いたにもかかわらず、後になって実装を断念した結果、コード上は存在しない「幻の機能」が爆誕してしまう可能性があります。そうすると、ドキュメントを読んだユーザーが「なんか動かない」と困ってしまいますので、仕様変更や機能を追加した際にはちゃんとドキュメントに反映できているか気をつけましょう。

<モジュール名>.ps1

Windowsモジュールでは、PowerShellのスクリプトがモジュール本体となります。win_zipで言えば、実際にファイルやディレクトリを圧縮する処理を記述します。以下が「win_zip.ps1」の記述内容です。

#!powershell

# Copyright: (c) 2021, Kento Yagisawa
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

#AnsibleRequires -CSharpUtil Ansible.Basic

$spec = @{
    options = @{
        # Need to support \* which type='path' does not, the path is expanded further down.
        src = @{ type = 'str'; required = $true }
        dest = @{ type = 'path'; required = $true }
    }
    supports_check_mode = $true
}
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)

$src = [Environment]::ExpandEnvironmentVariables($module.Params.src)
$dest = $module.Params.dest

$srcFile = [System.IO.Path]::GetFileName($src)
$compressionLevel = [System.IO.Compression.CompressionLevel]::Optimal
$encoding = New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false
$srcWildcard = $false

# If the path ends with '\*' we want to include the dir contents and not the dir itself
If ($src -match '\\\*$') {
    $srcWildcard = $true
    $src = $src.Substring(0, $src.Length - 2)
}

If (-not (Test-Path -LiteralPath $src)) {
    $module.FailJson("The source file or directory '$src' does not exist.")
}

If ($dest -notlike "*.zip") {
    $module.FailJson("The destination zip file path '$dest' need to be zip file path.")
}

If (Test-Path -LiteralPath $dest) {
    $module.Result.msg = "The destination zip file '$dest' already exists."
    $module.ExitJson()
}

# Check .NET v4.5 or later version exists or not
try {
    Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
}
catch {
    $module.FailJson(".NET Framework 4.5 or later version needs to be installed.", $_)
}

Function Compress-Zip($src, $dest) {
    If (-not $module.CheckMode) {
        If (Test-Path -LiteralPath $src -PathType Container) {
            [System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dest, $compressionLevel, (-not $srcWildcard), $encoding)
        }
        Else {
            $zip = [System.IO.Compression.ZipFile]::Open($dest, 'Update')
            try {
                [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $src, $srcFile, $compressionLevel)
            }
            finally {
                $zip.Dispose()
            }
        }
    }
    $module.Result.changed = $true
}

Compress-Zip -src $src -dest $dest

$module.ExitJson()

基本的な構造や正確なお作法については、公式ドキュメントを参照してください。ここでは、いくつかかいつまんで解説します。

$spec = @{
    options = @{
        # Need to support \* which type='path' does not, the path is expanded further down.
        src = @{ type = 'str'; required = $true }
        dest = @{ type = 'path'; required = $true }
    }
    supports_check_mode = $true
}

ここでは、受け付けるパラメーター( srcdest )とその種類を定義しています。「 src もpathじゃないか」と思われますが、コメントにあるようにワイルドカードに対応できるように、ここでは strを指定しています。また、チェックモード(Dry Run)に対応しているモジュールであることも明示しています。

# If the path ends with '\*' we want to include the dir contents and not the dir itself
If ($src -match '\\\*$') {
    $srcWildcard = $true
    $src = $src.Substring(0, $src.Length - 2)
}

ここで、「*」で src が終了する場合にはそれがワイルドカードであるという判定を行っています。

ちなみに、$module.ExitJson 関数を呼び出すと、処理が正常終了します。$module.FailJson を呼び出すと、実行失敗(Failed)を返却できます。以下は、実際に圧縮を行う前に正常なパラメータが渡っているか判断するための処理です。

If (-not (Test-Path -LiteralPath $src)) {
    $module.FailJson("The source file or directory '$src' does not exist.")
}

If ($dest -notlike "*.zip") {
    $module.FailJson("The destination zip file path '$dest' need to be zip file path.")
}

If (Test-Path -LiteralPath $dest) {
    $module.Result.msg = "The destination zip file '$dest' already exists."
    $module.ExitJson()
}

# Check .NET v4.5 or later version exists or not
try {
    Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
}
catch {
    $module.FailJson(".NET Framework 4.5 or later version needs to be installed.", $_)
}
  1. src に指定されたファイルもしくはディレクトリが存在しない場合
  2. dest で指定されたパスが .zip で終了していない(zipファイルではない)場合
  3. 同名のzipファイルが dest のパスに存在している場合
  4. 実行に必要な.NET Framework v4.5が存在していない場合

1,2,4の場合にエラーを返却、3の場合は実行の必要がないためそのまま終了するようにしています。4の.NET Frameworkのバージョン確認では、当初は.NETのバージョンを読み取り、そのバージョンの値が指定より小さかったらエラーとする処理としていましたが、レビュアーの方の指摘により、実際に Add-Typeで呼び出してみて、失敗したらエラーとする方がよいとのことでその方式に変更しています。

いよいよ実際の処理であるCompress-Zip関数です。このモジュールのキモとなる部分であり、対象がディレクトリの場合とファイルの場合で異なる処理を実行するようにしています。Zip圧縮の機能そのものは.NET Frameworkで提供されているため、そちらを利用します。モジュール開発当初は、Powershellのコマンドレット(Compress-Archive)を利用する予定でしたが、ファイルサイズが2GBまでに制限されること、対象のOSバージョンが限られることから.NET FrameworkのZipFileクラスを直接利用する方式に変更しています。

モジュールの最後で実際に関数を呼び出し、終了処理を記述すれば完成です。

Function Compress-Zip($src, $dest) {
    If (-not $module.CheckMode) {
        If (Test-Path -LiteralPath $src -PathType Container) {
            [System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dest, $compressionLevel, (-not $srcWildcard), $encoding)
        }
        Else {
            $zip = [System.IO.Compression.ZipFile]::Open($dest, 'Update')
            try {
                [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $src, $srcFile, $compressionLevel)
            }
            finally {
                $zip.Dispose()
            }
        }
    }
    $module.Result.changed = $true
}

Compress-Zip -src $src -dest $dest

$module.ExitJson()

ちなみに、 CreateFromDirectory のときは事前にZipFileをOpenする必要はないのですが、 CreateEntryFromFile の場合はOpenしておく必要があります。最終的にはOpenしたZipFileをDisposeする処理になっています。

後編では、地獄のCIからリリースまでについて解説していきます。最後までお読みいただきありがとうございました。

Merry Christmas and Happy Automation!!

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。