OCRと仮想マシンの自動操作で環境構築を自動化するフレームワークをシェルスクリプトで書きました

## はじめに

**とうとうやったよ！もう何度も手作業でOSをインストールしなくていいんだ！**

タイトルの通り、仮想マシンの環境構築をOCR+キー操作で自動化するツール（フレームワーク）をシェルスクリプトで作りました。[myvm（仮）プロジェクトページはこちら](https://github.com/ko1nksm/myvm)です。次の動画（画像をクリック）ではシェルスクリプトで書いた「[構築スクリプト](#構築スクリプト)」を使って FreeBSD を仮想マシンに自動でインストールしています。
<a href="https://user-images.githubusercontent.com/2453619/251927873-a8ff43de-271c-4d23-b073-99574b33a0d4.mp4">![myvm.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54918/46a40a5a-44bc-c9b3-df13-5064535aeae9.jpeg)
</a>

最近では手元のコンピュータ上に仮想マシンを作るということは減ったように思えます。おそらくクラウドサービスの普及や仮想マシンでやっていたことが WSL や Docker を使ってできるようになったのが理由でしょう。Linux を直接使わなくても Windows を Linux 相当のものとして使えるようになりました。私も Linux 環境が欲しい場合は WSL や Docker を使うのですが、これだけでは BSD 系の OS や Solaris などへの対応ができません。私にとってはこれらの環境も必要です。今までは仮想マシンに手作業で OS をインストールしていたのですが、さすがにいくつもの環境があると構築が大変です。もしディスクが壊れたりしたら同じ環境に戻すまでの大変さでやる気を失ってしまうでしょう。以前からこの問題を解決したいと考えていました。

他にも仮想マシンを自動構築する方法がいくつかあることは知っていますが、どれも Linux 以外の対応は不十分で、私にとっては面倒で不必要に難しく感じ、トラブルが発生すると何が起きているのか調べることが困難で使い慣れていないと逆に時間がかかってしまうものばかりでした。**myvm のコンセプトは「自分で簡単にメンテナンスできる」です**。他のツールのように実際に行っている環境構築処理を YAML ファイルや JSON ファイルの背後に隠してブラックボックスにしてしまうのではなく、構築スクリプトを書く人が何をやるのかを理解できるようにし、自分自身の手で環境構築を自動化するためのフレームワークです。ノウハウのいらない環境構築ツールを目指しています。

この記事では、myvm の仕組みや実現方法、シェルスクリプトで作ることのメリット、シェルスクリプトでどのようにして実装したかなど、説明されることが少ないシェルプログラミング技術を実用例を元に解説しています。

## 使用ソフトと仕組み

使用するソフトは標準的はコマンドを除いて以下のとおりです。

- Linux 上の仮想マシンの KVM
- KVM を CLI で操作する `virsh` (libvirt)
- 仮想マシンに OS をインストールするための `virt-install` (virtinst)
- CLI で使える OCR ソフトの `tesseract`
- 前処理で使用している GraphicsMagick (`gm`)

環境構築だけなら必須ではないのですが、構築スクリプトを書いたりデバッグするときに実行中の画面を見たり操作するために VNC Viewer を使います。

基本的な仕組みは単純です。`virsh` コマンドは「仮想マシンの画面のスクリーンショットを出力する機能」と「仮想マシンにキー入力を送信する機能」を持っているので、一定間隔でスクリーンショットを出力し、OCR ソフトにかけて文字列に変換し、特定の文字列に反応して仮想マシンにキー入力を送信するという流れです。つまり**人が仮想マシンに OS をインストールする手作業そのものを自動化**しています。そのため手作業で OS をインストールできる人なら誰でも構築スクリプトを書くことができますし、なにをやっているのかも明確になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54918/c4e1fdc2-ee4b-6306-9d8e-495a8cb2362a.png)

### メリットとデメリット

OCR とキー操作を使った方法のメリットの一つは多くの OS に対応が可能なことです。OS 固有の仕組みを使わないため、どの OS でも同じ方法で自動インストールを行う構築スクリプト書くことができます。前提条件はキーボードだけで OS のインストール作業が行えることぐらいです。おそらく Windows でも対応は可能でしょう（マイクロソフトアカウントを要求されて面倒になったため途中までしかテストしていません）。

また構築スクリプトの実行中に VNC 経由で画面を見たり操作したりできるのでデバッグが簡単です。構築スクリプトを書いている段階では途中で処理が失敗することは多々あります。失敗したときに最初からやり直すしか方法がなかったら時間がかかってしまいます。myvm ではキーをただ送信しているだけなので、仮想マシンから見れば構築スクリプトからの操作も VNC からの手動の操作も違いがありません。問題が発生したら途中で構築スクリプトを止めて、直ちに手作業で問題を確認したり修正したり残りの処理を続行したりするという一連の作業を自然な形で行うことができます。

デメリットは信頼性が低いことです。すべては OCR ソフトが正しく文字を読み取れるという前提で成り立っています。体感的に 9 割以上の精度で正しく読み取れているように思いますが、正しく読み取れないこともしばしばあります。例えば IPv6 を IPub と読み取る例がありました。OCR ソフトのアップデートで読み取る文字が変わってしまうこともあるでしょう。この問題には**スペルを修正可能にする仕組み**を実装することによって自動的ではないものの回避可能にしています。この問題のもう一つの解決策は各 OS 専用にフォントを学習させることです。現状でも高い精度で認識できているので今回はそこまではやっていませんが tesseract はフォントを学習させることができるので、各 OS 毎にインストールに必要な文字だけのフォントを学習させれば精度は大きく向上すると思われます。そしてその学習データをリポジトリに含めて配布すればより安定した自動構築が可能になるはずです。

### なぜシェルスクリプトで作ったの？

構築スクリプトをシェルスクリプトで書けるようにすると修正やデバッグが簡単で便利でわかりやすいからです。多くの人が思うよりも**柔軟で高い拡張性を実現することができます**。構築スクリプトがシェルスクリプトであることでもたらされる拡張性は他の言語で作っていてはなかなか実現できません。

理由の一つはシェルスクリプトでもこのようなものが作れるという技術デモと実証です。近年シェル言語が本来持っているプログラミング言語機能を活用したシェルプログラミングがロストテクノロジー化しつつある気がします。対話シェルでの使い方をそのままシェルスクリプトの書き写す程度の使い方しかされていません。それでは出来ることに限界があります。実は**対話シェルとシェルスクリプトでは必要となる技術や書き方が大きく異なります**。これは対話シェルでは人間が状況判断を行うために制御文をほとんど必要としないからです。シェルスクリプトは人がやっていた状況判断をプログラムで行うものなので、対話シェルではあまり必要としない制御文の使いこなし＝シェルプログラミングが重要になってきます。

そしてシェルプログラミングの技術を身につければ、シェルスクリプトでさまざまなコマンドを組み合わせてこのようなツールを作ることができます。作るだけならば他の言語の方が良いと思うかもしれませんが「**コマンドを組み合わせて何かを作るのであればシェル言語が最も簡単に作れる**」というのがシェルスクリプトで作った理由です。今回は `virsh` コマンドや `tesseract` コマンドなどいくつものコマンドを組み合わせて作っています。シェル言語は他の言語と異なりコマンドを組み合わせるために設計された専用の言語なのでこのような用途に適しています。しかしシェルプログラミング技術を知らないのでは使い捨ての小さなシェルスクリプトを書くのが限界になってしまっています。対話シェルの使い方をシェルスクリプトに書き写すだけという発想ではこのようなツールを作ることはできません。プログラミング言語としての基本機能を使うかどうかにシェルプログラミングか否かの境目があります。

シェル言語を使いこなせば大きなソフトウェアをシェルスクリプトで書くことができますが、そのためには思いついたコマンドをただ書いていくだけではなく他のプログラミング言語と同じような設計を行いシェルプログラム全体に構造をもたせる必要があります。他の言語ではフレームワークを誰かが作っているので、それに乗っかれば誰でも簡単に大きなプログラムが作れますが、シェルスクリプトの世界にはそのようなものがあまりないためフレームワークから自作する必要があります。フレームワークの自作はさほど難しいものではないのですが例がなければ作ることは難しいでしょう。本記事で開発した自動構築ツールによって、フレームワークの作り方やシェルプログラミングがどういうものであるかを少しでも伝えられたら幸いです。

また、あまり語られることは少ないですがシェル言語は（内部）DSL を作るのにも適しています。内部 DSL は一般的な言語では書き方を工夫して自然言語に見えるような書き方をしますが、シェル言語は単語をスペースで区切ったコマンドを実行するという元より自然言語に違い構文を使います。例えば「`this is a pen`」はシェル言語として正しい文法です。構文をシンプルにした結果、自然言語と同等の構文になったという点で他の言語と異なる特徴です。myvm ではシェル言語のこの特徴を活かし、構築スクリプトを DSLで記述できるようにしています。関数の再定義や動的なコードの生成など対話シェルでは使うことのないシェル言語のメタプログラミング機能は、DSL を作るだけではなくデフォルト処理を置き換えたりフックやプラグインの仕組みを作ったりとタスクランナーのようなフレームワークをシェルスクリプトで書くのにとても便利です。

### 「アレ」を使えばいいのでは？

仮想マシンを自動的に構築するツールと聞いたら真っ先に思いつくのが Vagrant しょう。Vagrant を使えば簡単に開発環境を作ることができますが、それは自分が欲しい仮想マシンのイメージ (Vagrant Box) が用意されている場合に限った話です。もちろん Vagrant Box を自分で作ることはできます。作る方法の一つは手作業で仮想マシンを作り Vagrant Box 化することですが、そもそもそれが面倒だというのが話の発端です。この面倒な作業を減らすためのツールが Packer なのですが、使いこなすには各 OS の自動インストールの仕組みや、それを Packer とどう組み合わせていくかなどの前提知識が必要になります。結局のところ Packer を使いこなすのにもノウハウが必要で、自分が望んだ仮想マシンイメージを作るのは大変です。

myvm の基本的な目的は ssh で接続できる最低限のベースイメージを作成する所までです。ssh で接続さえできれば、あとはシェルスクリプトなどで残りの設定処理を行うことは簡単なことです。もちろん残りの処理を myvm で行うこともできるのですが、基本は ssh で接続できるベースイメージの作成を目的としています。したがって myvm が競合するとしたら Vagrant よりも Packer です。おそらく myvm を使って Packer の代わりに Vagrant Box を作ることもできるはずです。Vagrant Box 化する理由はあまりないのですが、Vagrant Box 化すれば [Vagrant Cloud](https://app.vagrantup.com/boxes/search) を数 GB もある巨大な OS のイメージ置き場として使えるなぁ、などとセコいことを考えています。

Linux では OS の自動インストールを行う Preseed（Debian 系）や Kickstart（RedHat系）という仕組みがあり、`virt-install` もこれらに対応しています。Preseed や Kickstart を使えば Linux を簡単にインストールすることができますが、言うまでもなくこれは Linux 専用の仕組みです。他の OS でも同様の仕組みはあるのかもしれませんが、OS 毎に自動インストール仕組みを調べるのは大変ですし、そのような仕組みがない可能性もあります。

これらの仕組みに共通する問題点は、手作業での OS のインストールの方法とは異なる別の自動インストールの仕組みを使わなければいけないということです。OS のインストーラーはよくできており手作業での OS のインストールは簡単です。さらにネット上にインストール手順の記事や動画はいくらでもあります。手作業の OS のインストーラーの入力内容をそのまま入力すればインストール作業は完了するはずですよね？ よくできたインストーラーがあるのになぜ全く別の仕組みを使わなければならないのでしょうか？ 私には簡単な問題を難しくしているだけに思えます。

Packer は構築処理のコード化には成功しましたが、それはインストール作業のコード化ではありません。myvm の構築スクリプトはインストール作業のコード化です。したがって人が読んで手作業で OS をインストールできる「**実行可能な手順書**」になっています。シェルスクリプトよりも JSON や YAML の方がわかりやすいみたいなことはよく言われていますがそれは思い込みに過ぎません。JSON や YAML にシェルスクリプトを埋め込む時点で破綻しています。どちらにしろシェルスクリプトを書くのであれば全部シェルスクリプトで良いではありませんか。そうすれば JSON や YAML にはないシェル言語が持つプログラミング言語機能や外部コマンドをそのまま使うことができます。シェルスクリプトが十分わかりやすいということは後述の構築スクリプトを見ればわかるでしょう。

## 自動構築ツール（フレームワーク）

### ディレクトリ・ファイル構成

元々自分用の仮想マシンの構築スクリプト郡として作り始めたのでリポジトリには自動構築ツールと本来分離すべき構築スクリプトが混ざっています。将来的に構築スクリプトを分離すると思いますが今すぐやる予定はありません。リポジトリには次のようなファイルがあります。利用者視点でいうと使用するツールは `myvm` で、各環境用の構築スクリプト（freebsd/13.2.shrc 等）を以下のようなディレクトリ構成で作ります。

```
|-- myvm                自動構築ツール（ユーザーが直接使うコマンド）
|
| 以下は内部的に利用するライブラリやコマンド
|
|-- lib
|   `-- vm.sh           virsh 関連の関数
|
|-- libexec
|   |-- sendkey.sh      virsh send-key 関連のヘルパーコマンド
|   `-- virsh-edit.sh   virsh edit 関連のヘルパーコマンド
|
|
|  ======== 以下は構築スクリプト（将来的に別リポジトリに分離する） ========
|
|-- freebsd             FreeBSD 用構築スクリプト
|   |-- 12.4.shrc           FreeBSD 12.4 用
|   |-- 13.1.shrc           FreeBSD 13.1 用
|   |-- 13.2.shrc           FreeBSD 13.2 用
|   `-- 14.0-current.shrc   FreeBSD 14.0（開発中）用
|
| 以下のディレクトリも同様のファイル構成
|
|-- debian             Debian 用構築スクリプト
|-- minix              Minix 用構築スクリプト
`-- solaris            Solaris 用構築スクリプト
```

### 自動構築ツール (myvm)

`myvm` は仮想マシンを構築したり削除したりするためのツールです。内部で呼び出している `libexec` 以下のヘルパーコマンドを含めて 500 行程度の小さなシェルスクリプトです（作り込めばもっと増えると思いますが）。実装は取りあえず動くものを作らないと何が必要かよくわからんということで、ちゃんと設計しないで作ったので少々雑です。現在以下のようなサブコマンドを実装していますが、まだ自分用のツールの段階なので仕様はあとで大きく変更すると思います。KVM 以外にも対応したいと思っていますが、その場合は各仮想マシンのコマンドの仕様をうまく抽象化するような設計にしなければならないでしょう。

```
Usage: myvm run <shrc-file> [<tasks> | --step-on]...
  環境構築スクリプト (shrc-file) に定義された各タスク（関数）を実行する。

Usage: myvm sendkey <shrc-file> [--ocr] [<keys>]...
　仮想マシンにキーを送信する。

Usage: myvm ocr <shrc-file>
　OCR の結果を出力する。

Usage: myvm vnc <shrc-file> [enable | disable | display]
　VNC 接続を有効にしたり無効にしたり接続先アドレスを出力する。

Usage: myvm demolish <shrc-file>
　指定した仮想マシンを削除（停止＋ディクスの消去）する。
```

### 構築スクリプト

構築スクリプトはインストールする各 OS ごとに作成するスクリプトです。可読性を最大限に高めるために作った独自の DSL（ドメイン固有言語）で記述します。構築スクリプトは普通のシェルスクリプトなので変数や `if` や `for` などを普通に使うことができます。JSON や YAML をハックして手続き型言語的な拡張を行うようなダサい真似は必要ありません。一般的な構築スクリプトでは制御命令を使う必要ないかもしれませんが、条件に応じて構築内容を変更したりするのに使うことができます。特定の OS に依存しないような処理などはファイルを分割してコードを共通化したり、追加の DSL を定義したり外部コマンドを利用したりすることもできます。myvm の内部処理（関数）を構築スクリプトで置き換えることだって可能だったりします。このような柔軟性と拡張性があるのは構築スクリプトがプレーンなごく普通のシェルスクリプトだからです。

さてここまで話を引っ張ってきましたが、以下が仮想マシンを作成し MINIX 3.3.0 をインストールする構築スクリプトです。`do_build` 関数（`build` タスク）は仮想マシンを生成するためのタスク、`do_setup` （`setup` タスク）は OS のインストール処理を行うタスクです。ここで定義したタスクは `myvm run` サブコマンドによって実行されます。まずはざっくりと読んでください。どうでしょう？難しいところがなにもないと思いませんか？

```sh
do_build() {
  vm.build --name "$DOMAIN" --osinfo detect=on,require=off \
    --vcpus 1 --memory 256 --disk size=10 --network bridge='br0' \
    --cdrom "$DISK_DIR/minix_R3.3.0-588a35b.iso" \
    --graphics vnc,listen='0.0.0.0' --noautoconsole

  prompt 'Regular MINIX 3' {ENTER}
}

do_setup() {
  prompt 'Type "root" at the login prompt' root {ENTER}
  prompt 'type "setup" and hit enter to start the installation process' setup {ENTER}
  prompt 'If you see a colon (:) then you should hit ENTER to continue' {ENTER}
  prompt 'Keyboard type?' {ENTER} # us-std
  prompt 'Press ENTER for automatic mode' {ENTER}
  prompt 'Enter the disk number to use' {ENTER} # 0
  prompt 'Enter the region number to use' {ENTER} # 0
  prompt 'Are you sure you want to continue?' yes {ENTER}
  prompt 'How big do you want your /home' {ENTER} # disk size
  prompt 'Ok?' y {ENTER}
  prompt 'Block size in kilobytes' {ENTER}

  timeout 300 "File copying"

  prompt 'Ethernet card' {ENTER}
  prompt 'Automatically using DHCP' {ENTER}
  prompt "Please type 'reboot" reboot {ENTER}

  vm.restart

  prompt 'login' root {ENTER}

  enter 'passwd'
  prompt 'New password' "$ROOT_PASS" {ENTER}
  prompt 'Retype new password:' "$ROOT_PASS" {ENTER}

  begin_shell 'Install sshd'
  - 'pkgin -y update'
  - 'pkgin -y install openssh'
  - '/usr/pkg/etc/rc.d/sshd start'
  end_shell
  waitfor "Install sshd success" "Install sshd failure"

  enter 'shutdown -p now'

  vm.vnc disable
}

spelling_correction() {
  sed '
    s|"setup |"setup" |g
    s|0k?|Ok?|g
    s|shome|/home|g
  '
}
```

**私が言っている「シェルスクリプトで書く」とはこのようなコードを書くことです**。

`do_build` で使用している `vm.build` は `virt-install` コマンドを呼び出しており引数は `virt-install` にそのまま渡されます。`do_setup` の内容は手動でインストールするときに入力する内容と同じです。基本的な流れは `prompt` の第一引数が画面に表示される質問文（実際には画面上の文字列ならなんでもよい）で、この質問文が出力されるのを待って残りの引数を質問の回答として入力します。おっと、質問文を書くのが面倒になると思った方はいませんか？ 私は質問文をキーボードで手打ちなんかしていません。OCR が読み取ったものをコピペしています。

これらの DSL の内部では `virsh screenshot` や `virsh send-key` を呼び出しており内部で複雑なことをしています。しかしそのような実装の詳細が構築スクリプトに現れることはありません。DSL を定義することで構築スクリプトは「本質的なこと」だけに集中することができ**シェルスクリプトは読みやすくなります**。シェルスクリプトは読みやすいとか読みにくいではなく自分で読みやすくするわけです。このような仕組みを作るのに必要な技術がシェルプログラミングです。

ここでシェルプログラミングを知らない場合、構築スクリプトに `virsh screenshot` や `virsh send-key` を直接書くことになるでしょう。構築スクリプトの中に本質的ではない処理が混ざってしまうのだから、そりゃ読みにくくなるに決まっています。それはシェルスクリプトが悪いのではなく作り方が悪いのです。シェルスクリプトを活用するにはシェルプログラミングの技術が重要なのですが、残念なことにこの技術はあまり普及していないようです。対話シェルで入力しているコマンドをそのまま書き写し、自分でメンテナンス不可能なシェルスクリプトを書いておきながら、それをシェルスクリプトのせいにするのは残念なことです。

#### タスク関数の分割と統合による構築スクリプトの作成

`do_build` 関数と `do_setup` 関数はフレームワークで要求している名前ではありません。単に私がそう名付けたというだけで、`do_` で始まる関数名で自由にいくつでも作ることができます。仮想マシンの作成をしているだけの `build` タスクは置いといて `setup` タスクの話をします。今から構築スクリプトを書くとして `do_setup` 関数の中身は最初は空です。構築スクリプトの作成は VNC の画面と OCR の結果を見つつ `do_setup` 関数の中身を書いていきます。

OS のインストーラーが最初にしてくる質問は使用しているキーボードのレイアウトであることが多いです。質問でインストーラーの画面が停止したら `myvm ocr` コマンドを使って OCR の結果をテキストで出力します。その中から質問文として適切と思われるメッセージを取り出してそれを `prompt` の最初の引数にコピペします。ちなみに OCR のテキストの改行は無視（スペースとして扱われる）ので注意してください。また複数の連続するスペースは一つとみなされます。もし適切と思われるメッセージが OCR で正しく読み取れていない場合、構築スクリプトに `spelling_correction` 関数（次項参照）を定義することで文字列を修正することができます。質問文を書いたらその回答としてのキー操作を残りの引数として書きます。この作業の繰り返しで `do_setup` を記述していきます。

さてある程度、もしくは全部書き終えたら `do_setup` を実行しましょう。正しく書けたと思っても意外とミスはあるものです。そういう場合は `do_setup` 関数を分割します。私は問題なく動いているところまでを `do_setup` 関数に、次に一歩だけ実行したい処理を `do_next` 関数にし、残りを `do_rest` 関数にしています。例えば次のような感じです。

```sh
do_setup() {
  prompt 'Type "root" at the login prompt' root {ENTER}
  prompt 'type "setup" and hit enter to start the installation process' setup {ENTER}
  prompt 'If you see a colon (:) then you should hit ENTER to continue' {ENTER}
  prompt 'Keyboard type?' {ENTER} # us-std
  　　︙
}

　　　↓

do_setup() {
  prompt 'Type "root" at the login prompt' root {ENTER}
  prompt 'type "setup" and hit enter to start the installation process' setup {ENTER}
}

do_next() {
  prompt 'If you see a colon (:) then you should hit ENTER to continue' {ENTER}
}

do_rest() {
  prompt 'Keyboard type?' {ENTER} # us-std
  　　︙
}
```

このように分割するのは簡単な作業です。余談ですが構築処理を実行中に構築スクリプトを書き換えても問題ありません。[とある事件](https://xtech.nikkei.com/atcl/nxt/column/18/01157/012700053/)で勘違いが生まれているのではないかと思いますが、シェルスクリプトは実行中にファイルを書き換えたからといって書き換えたファイルを読み込むわけではありません。シェルは必要な所まで行単位で読み込み、次に実行する行が「まだ読み込まれていない場合」に実行直前に読み込むだけです。構築スクリプトの各タスクは関数になっており、フレームワークの仕組み上構築スクリプトは実行前にあらかじめ読み込まれます。タスクの実行中に構築スクリプトはすべてメモリ上に読み込まれているため、書き換えたところで書き換え後の内容を読み込むことはありません。つまり構築処理を実行している最中に構築スクリプトを見直して修正することが可能です。

さて関数を `do_setup`、`do_next`、`do_rest` の3つのタスクに分割しました。`myvm run` は引数で指定することでそれぞれのタスクを実行することができます。おそらく VNC で手作業でつじつまを合わせたり現在の状況を考慮しながら `do_next` を実行することになるでしょう。問題なく動けばそのコードを `do_setup` に移動し、`do_rest` から次のコードを持ってきます。そして問題箇所をすべて修正したら残りの `do_rest` を実行することができます。問題がなければ一つの `do_setup` にまとめます。このようにタスクはただのシェル関数でキー操作をしているだけという単純な仕組みであるため、VNC 経由で手作業で画面を操作したりして調査しながら現状を把握しタスク関数を分割し、適切な場所から処理を再開したりしてタスク関数を作っていくことができます。この流れはとても柔軟でメンテナンスがしやすいです。

#### 文字の出力待ちとスペルの修正機能

OCR は正しく文字を読み取れない場合があります。正しく読み取れない文字は構築スクリプトに `spelling_correction` 関数を定義してスペルの修正を行うことができます。例えば次の例では `/home` を `shome` と読み取ってしまうためそれを修正しています。アドホックな対応方法ですが、これにより構築スクリプトのメイン部分の文字を正しい文字で書くことができます。将来の OCR のアップデートで正しく読み取れるようになった場合でも修正する必要はありません。

```sh
spelling_correction() {
  sed '
    s|shome|/home|g
  '
}
```

`spelling_correction` 関数はデフォルトでは、`myvm` スクリプト内で次のような関数が定義されています。`cat` コマンドは入力をそのまま出力しているだけなので何も変化しません。

```sh
spelling_correction() {
  cat
}
```

フレームワーク本体であらかじめ `spelling_correction` 関数が定義されているため、構築スクリプトで `spelling_correction` 関数を定義しなくても「何もしないという動作」で問題なく動作します。構築スクリプトに `spelling_correction` 関数を書いた場合は、デフォルトの `spelling_correction` 関数が再定義されることで、独自のスペル修正処理に置き換わります。この手法はかなり強力でフレームワークで提供されている機能を含めて独自の処理に置き換え可能であるという拡張性が実現されています。例えばタスクの実行で存在しないタスク名を指定した場合以下のようなエラーが出力されます。

```sh
./myvm run minix/3.3.0.shrc no_task_name
myvm (Domain: minix3.3.0, Hostname: vm-minix3-3-0)
Configuration file loaded from /home/koichi/.config/myvm.

Task 'no_task_name' not defined
Aborted.
```

これは構築スクリプトに関数が定義されているかを調べているのではなく、実行前に `eval` コマンドで エラーを出力する `do_no_task_name` 関数を定義しており、もし指定したタスクが構築スクリプト内でタスクが定義されていればそのタスクが呼び出され、タスクが定義されていない場合はエラーが出力されるという巧妙な実装になっています。

さて、ところで上記の出力には `/home/koichi/.config/myvm` を読み込んだと出力されています。このファイルは独自の変数を定義するためのシェルスクリプトなのですが、このファイルに関数を定義することで、（構築スクリプトごとではない）グローバルなフレームワークの関数の置き換えを行うことができます。シェル関数の再定義は単純な仕組みですが強力な拡張機能を実現することができます。

### 構築スクリプト用のDSL

DSL は今のところ以下のものが定義されています。

- `prompt <パターン> [キー]...` 特定の文字列が出力されるのを待ってキーを入力する
- `waitfor <成功時のパターン> [<失敗時のパターン>]` 文字列が出力されるのを待つ
- `sendkey [キー]...` キーを入力する
- `enter [文字列]...` 文字列を入力する。各引数の末尾には改行が含まれる
- `timeout <数字>` 次の `waitfor` のタイムアウト時間を延長する
- `begin_shell` / `end_shell` 仮想マシン上で実行するシェルスクリプトを書く
- `begin_file` / `end_file` 仮想マシン上にファイルを作成する
- `copy_file` 仮想マシン上にファイルをコピーする
- `step_on` デバッグのためのステップ実行を ON にする

`prompt` と `waitfor` のパターンには任意の文字列にマッチする `*` のみ使うことができます。`?` などには対応していません。おそらく必要ないと思っています。キーまたは文字列の入力はすべて `virsh send-key` によってキーの送信として実現していることに注意してください。`begin_shell` によるシェルスクリプトの実行や `begin_file` や `copy_file` もファイルを送り込んだりしているわけではなく、キーを送信して実現しています。

#### 質問とその回答を入力する `prompt`

OS のインストールで最も使用するのが `prompt` です。これはインストーラーからの質問に対して、その回答を入力するためのものです。`prompt` 関数は内部で、`waitfor` 関数と `sendkey` 関数を呼び出すだけの簡単な関数です。

#### 文字列が出力されるのを待つ `waitfor`

構築スクリプトではまず特定の文字列が画面に出力されるのを `waitfor` を使って待ちます。この時 `waitfor` には質問文を記述するようにすると良いです。例えば `waitfor 'What type of keyboard do you have'` のような文章です。質問文にすると送信するキー入力の内容が質問文の答えになるため構築スクリプトの可読性が上がります。

**`waitfor` の文章は手入力する必要はありません**。この文章は OCR の結果からコピペすることができます。`myvm ocr` コマンドで現在の画面の OCR 結果を取得することができるので、その中から `waitfor` の引数にするのにふさわしい文章を見つけ出します。なお OCR の文章と `waitfor` の文字列の両方で**改行はスペースとして解釈され、複数のスペースは一つとして扱われる**ことに注意してください。これは文章が折り返されていてもマッチするようにするためです。

`waitfor` は省略可能な二番目の引数を渡すことができます。二番目の引数は失敗したときのパターンで、このパターンにマッチする文字列を見つけると構築スクリプトはエラー終了します。

#### キーの入力について `sendkey`/`enter`

myvm ではキーの入力として `sendkey` 関数を、文字列の入力として `enter` 関数を定義しています。実はどちらも `virsh send-key` を使っていることに違いはありません。`sendkey` 関数は主にインストーラーのメニューを操作するときに使い、`enter` はコマンドプロンプト（のようなもの）に一行入力するために使います。また `sendkey` は `{ENTER}` のような `{...}` で囲まれた特殊なキー（キーボードにキーがあるが文字ではない）を入力することができるのに対して `enter` はすべてを文字として入力するという違いがあります。

`sendkey`（`prompt` 含む）で使用できる特殊キーには次のようなものがあります。これらはインストーラーでよく使うキーです。

  - `{F1}` `{F2}` `{F3}` `{F4}` `{F5}` `{F6}` `{F7}` `{F8}` `{F9}` `{F10}` `{F11}` `{F12}`
  - `{UP}` `{DOWN}` `{LEFT}` `{RIGHT}` `{ENTER}` `{TAB}` `{HOME}` `{END}` `{BS}` `{DEL}`
  - `{PAGEDOWN}` `{PAGEUP}` `{INSERT}` `{CAPSLOCK}` `{SCROLLLOCK}` `{SYSRQ}` `{BREAK}` `{SPACE}`

同じ特殊キーを連続して複数回入力する時には `{BS}x50` や `{DOWN}x10` のような書き方を使うことができます。

Windows では `ALT` + `I` のようなショートカットキーを用いるため次のような記法にも対応しています。この記法は少し気に入ってないので変更するかもしれません。

  - `{ALT}-A` ... `{ALT}-Z`, `{ALT}-0` ... `{ALT}-9`
  - `{CTRL}-A` ... `{CTRL}-Z`, `{CTRL}-0` ... `{CTRL}-9`
  - `{ALT+CTRL}-A` ... `{ALT+CTRL}-Z`, `{ALT+CTRL}-0` ... `{ALT+CTRL}-9`
  - `{CTRL+ALT}-A` ... `{CTRL+ALT}-Z`, `{CTRL+ALT}-0` ... `{CTRL+ALT}-9`

それ以外の文字列はそのまま通常のキーとして送信されます。

#### 仮想マシンでは英語キーボードを使う

`sendkey` や `enter` は引数で指定した文字列を仮想マシンに送信します。この時 `Apple` と指定すると、ちゃんと `Apple` と入力されます。当たり前のようですが実は `virsh send-key` で指定するのは文字コードではなくキーコードです。手元のキーボードのテンキーではない方の `5` を見てください。`5` というキーは存在しますが `%` も同じキーです。`5` と `%` は文字としては異なりますがキーとしては同じなのです。`a` と `A` もキーコードは同じです。

`virsh send-key` はキーコード（数値または名前）を指定して呼び出します。例えば `a` は `KEY_A` です。では `A` はどうなるでしょうか？ （caps lock がオフであれば）`KEY_LEFTSHIFT KEY_A` です。`Apple` と入力したい場合は `virsh send-key KEY_LEFTSHIFT KEY_A`、`virsh send-key KEY_P`、`virsh send-key KEY_P`、`virsh send-key KEY_L`、`virsh send-key KEY_E` と一文字ずつ実行する必要があります。一つの `virsh send-key` で複数のキーが同時に指定された場合、それらは仮想マシンに同時に送信されランダムな順番で到着する可能性があることがドキュメントに明記されています。さすがにこのような面倒なことをしたくないので myvm（正確には `sendkey.sh`）では文字列を各文字に分解して送信しています。文字とキーコードの対応コードを長々と書きたくなかったので、コードを動的に生成しています。そのため `sendkey.sh` は少々複雑です。少し無駄があるのでそのうち改善する予定です。

さて、では `:` を送信したい場合はどうすればよいでしょうか？ 実は `KEY_COLON` というキー名はありません。なぜなら「英語キーボード」では `:` と `;` は同じキーだからです。ちなみに日本語キーボードでは別のキーです。少なくとも現在の `virsh send-key` は英語キーボードを前提として作られています。したがって仮想マシンに（仮想的に）接続するキーボードは実際に使っている物理的なキーボードが日本語キーボードの場合でも英語キーボードにしなければならず、各 OS の設定も英語キーボードに設定する必要があります。おそらく日本語キーボードのキーとキーコードのマッピングを定義してあげれば対応可能なのではないかと思いますが、キーボードの種類はいくつもあるのでそれらに対応するのは大変です。`virsh send-key` がその他の種類のキーボードに対応してくれれば良いのですが、現状では `virsh send-key` を正しく動かすために仮想マシンには英語キーボードを接続しなければならないことに注意してください。

キーボードの種類が問題なるのは VNC での操作とシリアルコンソールで接続したときぐらいです。ssh でログインしたときには関係ないのでそんなに困ることはないでしょう。もしどうしても日本語キーボードを接続したいのであれば、インストール完了後に設定を変更すると良いと思います。もちろん設定を変更すると `virsh send-key` も myvm も正しく文字を入力することはできなくなります。ちなみに libvert で扱うキーコード一覧は `libvirt-clients` に含まれており、`man virkeyname-linux` や `man virtkeycode-linux` などで調べることができます。

#### タイムアウト時間を伸ばす `timeout`

`waitfor` は特定の文字列の出力を監視しますが、タイムアウト値が設定されておりデフォルトでは 60 秒をすぎるとタイムアウトでエラーとなります。これを伸ばすのが `timeout` 関数です。伸ばす効果があるのは次の `waitfor` の呼び出しだけで、次の次の `waitfor` の呼び出しには影響しません。内部で `waitfor` を呼び出している `prompt` にも適用されます。最初は以下のように `waitfor` の引数でタイムアウトの値を設定できるようにしていたのですが、なぜタイムアウトを長くしたのかその理由がわからないということで仕様を変更しました。

```sh
  waitfor 'Block size in kilobytes'
  sendkeys {ENTER}

  waitfor 'Ethernet card' 300
  sendkeys {ENTER}
```

上記のコードは正しく動作しますが、300 秒に伸ばした理由がわかりません。伸ばした理由は間に多数のファイルコピー処理が行われるからなのですが、その理由を書くところがありません。`timeout` 関数を導入したことでタイムアウト時間を伸ばした理由を示すことができるようになりました。

```sh
  waitfor 'Block size in kilobytes'
  sendkeys {ENTER}

  timeout 300 "File copying"

  waitfor 'Ethernet card'
  sendkeys {ENTER}
```

#### シェルスクリプトを実行 `begin_shell`

`begin_shell` と `end_shell` を使って仮想マシン上で一連のシェルスクリプトを実行することができます。

```sh
  # 注意 この時点では root でログインしておりシェル上にいます

  begin_shell 'Install sshd'
  - 'pkgin -y update'
  - 'pkgin -y install openssh'
  - '/usr/pkg/etc/rc.d/sshd start'
  end_shell
  waitfor "Install sshd success" "Install sshd failure"

  enter 'shutdown -p now'
```

最後の行では `enter 'shutdown -p now'` でシャットダウンコマンドを実行しています。これと同じように次のように書けばいいのでは？と思うかもしれませんがこれはうまくいきません。

```sh
  enter 'pkgin -y update'
  enter 'pkgin -y install openssh'
  enter '/usr/pkg/etc/rc.d/sshd start'
```

なぜならキー入力はコマンドの実行と非同期で行われるからです。つまり `pkgin -y update` （パッケージのデータベースの更新）を実行中に `pkgin -y install openssh` がキー入力されてしまいます。もしかしたら先行入力していても動くかもしれませんが、それに期待するよりかは同期的に動くようにしたほうが良いでしょう。実際の内容とは少し異なり簡略化していますが、`begin_shell` から `end_shell` の間は次のようなシェルスクリプト（を実行するキー入力）に置き換わります。

```sh
sh << 'MYVM_SHELL_HEREDOC'
pkgin -y update
pkgin -y install openssh
/usr/pkg/etc/rc.d/sshd start
MYVM_SHELL_HEREDOC
```

これによって各コマンドの呼び出しを同期的に実行することができます。ここで気になるのが `begin_shell` から `end_shell` の間にある `-` でしょう。`-` は YAML のリストに似せて選んだ名前ですが、実は `-` はコマンド名です。実装は単に `enter` シェル関数を呼び出しているだけです。シェル言語で `-` という名前のコマンドを作れることに驚いたかもしれません。実は bash など一部のシェルではシェル関数に `-` という関数名をつけることができます。`-` 関数は単に `enter` 関数を呼び出しているだけです。

```sh
# bash、zsh、mksh では問題なく関数を定義できる
-() { enter "$@"; }
```

その一方で、dash などではシェル関数名として `-` という文字をつけらません。そのようなシェルでは `alias` を使って `-` を `enter` に置き換えています。

```sh
alias "-=echo"

# シェルによっては - を alias のオプションと見なす場合があるので
# その場合には -- でオプションの終わりを明示しなければならない
alias -- "-=enter"
```

 関数またはエイリアスによって、`-` というコマンドを使うことを可能にしています。この方法は以前から思いついていたものの実用するのは私にとって初めての試みであり、一応すべてのシェルで動作していると思いますが、もし何か問題が見つかった場合には別の文字に変更するかもしれません。

上記のコードからは省略していますが、`end_shell` は一連のシェルスクリプトを実行したあとに正常終了したら `Install sshd success`（`Install sshd` は `begin_shell` で指定したもの）、エラー終了したら `Install sshd failure` という文字列を出力します。これを利用して一連のシェルスクリプトの実行完了を `waitfor` で待つことができます。自動的に待つようにすることも考えたのですが、仕組み的に `Install sshd success` または `Install sshd failure` 文字を OCR で正しく読み取れない可能性があり内部に隠蔽したくないと考えたため、ユーザーが意識して `waitfor` で待たなければならない仕様にしています。

#### ファイルの作成 `begin_file`/`copy_file`

この機能は任意のファイルを作成できるように設計されていますが、主に ssh 公開鍵を仮想マシン上に作成するために追加しました。myvm の基本は ssh でログインできる仮想マシンを作るところまでです。しかし、ssh でログインできるということはシェルスクリプトを送り込んでそれ以上のことをやることができます。それ以上の事を自動的にやるには ssh で自動ログインができなければなりません。そのためには公開鍵を仮想マシン上に作成する必要があります。

```sh
  begin_file /var/tmp/text.txt
  - line1
  - line2
  end_file

  copy_file "authorized_keys" "/root/.ssh/authorized_keys"
  enter 'chmod 600 /root/.ssh/authorized_keys'
```

ちなみにファイルを仮想マシンにコピーする方法は他にも、ネットワークからダウンロードしたりディレクトリから ISO イメージを作成して CD-ROM としてアタッチする方法が考えられます。これらの方法でももちろん構いませんが `begin_file` / `copy_file` はシェルのリダイレクト機能とキー入力だけで行っているという特徴があります。

#### デバッグのための `step_on`

`step_on` を実行すると、それ以降の `sendkey` がステップ実行となり実行のたびに実行するかを質問するようになります。質問では、実行以外にスキップや以降をすべて実行や OCR の出力を行うこともできます。また `myvm run` の `--step-on` オプションでもステップ実行を有効にすることができます。

myvm では好きな場所で停止して、構築スクリプトを修正して再開するのが容易であるため、なくてもそれほど困らないのですが意外と便利です。ちなみに Packer でも似たような機能があるため対抗して実装しました。

### 仮想マシンを操作する `vm` オブジェクト

構築スクリプトで使用可能な仮想マシンを操作するためのコマンドとして以下のものが定義されています。これらは包括的なものではなく私が必要になったものを適当に追加したものなので、将来仕様は大きく変わると思います。

- `vm.build` 仮想マシンを作成する
- `vm.stop` 仮想マシンを停止する
- `vm.restart` 仮想マシンの停止を待ってからスタートする
- `vm.setmem` 仮想マシンメモリを設定する
- `vm.vnc` 仮想マシンの VNC サーバーを有効または無効にする
- `vm.disk` ディスクを追加する

さて、さらっと `vm` オブジェクトと書きましたがシェル言語にはオブジェクトという概念はありません。`vm` と `build` の間が `.` で区切られていることに注意してください。bash などでは `vm.build` という名前のシェル関数を定義できますが、dash などでは定義できません。にもかかわらず構築スクリプトで `vm.build` と書くことができます。種明かしをするとここでも `alias` を使って `vm.build` を `vm_build` に変更しています。この方法によって POSIX sh で動作するように移植可能な形で `vm` オブジェクトという概念を擬似的に実現しています。些細なことですが、このような細かさが可読性の向上に繋がります。

## OCR (Tesseract) に関する注意点

OCR に Tesseract を使用しているので、まず Tesseract がインストールされていなければいけません。Tesseract は新しいバージョン（5系？）を使用してください。設定を詳しく調べれば解決できるのかもしれませんが、古いバージョン（4系？）は OCR の読み取り結果がかなりおかしな物となってしまいました。

- https://github.com/tesseract-ocr/tesseract OCR プログラム
- https://github.com/tesseract-ocr/tessdata OCR 用データ

## 参考 acme.sh + VM Actions

OCR を利用した仮想マシンの自動構築の仕組みは以前から考えていたのですが、なぜ誰もこれと同じような方法を使おうとしないのだろうと不思議でした。実は少し前に myvm と似たような方法でテストしているプロジェクトを見つけました。それが [acme.sh](https://github.com/acmesh-official/acme.sh) です。このプロジェクトでは、FreeBSD や Solaris などのプロジェクトで自動テストが行われています。

ただテストの仕組みが複雑で、まず [VM Actions](https://github.com/vmactions) という GitHub Actions を利用していて、そこにある程度の数の各 VM（FreeBSD、OpenBSD、NetBSD、DragonFlyBSD、Solaris）のテストランナーが用意されているのですが、そこから共通で使われている [vbox](https://github.com/vmactions/vbox) の中で OCR が利用されています。`pytesseract` という文字が見えるので、同じく Tesseract が使われているようです。`cv2` (OpenCV) も使っているので精度を上げるため（？）に前処理も行われているようです。そして `vboxmanage` コマンドが使われているので、これは VirtualBox 専用のようです。

使用している技術は異なりますが、大雑把に言って基本的な考え方は同じと言えるでしょう。VM Actions はこれはこれで自動テストを行うのには使えそうな気がします。私は動作検証環境用として手元のマシンに仮想マシンを作るという目的の違いがあり、それをシェルスクリプトでもっとシンプルに実装できると考えました。それはともかく OCR を使った仕組みがうまく機能することがわかったので、私もやってみようと思った次第です。

## さいごに

私の目的は「個人的な仮想マシンの構築を自動化する方法の実現」です。なので myvm（仮）は今の時点で私の目的を達成しています。myvm は便利だと思うので、いずれは汎用的に使えるツールにしたいと思っているのですが、今すぐやる予定はありません。汎用的に使えるツールにするときには KVM 以外にも対応したいと考えています。理論的には仮想マシンを管理するコマンドにスクリーンショット機能とキー操作機能があれば、他の仮想マシンなどにも対応できるはずです。簡単に調べたところ VirtualBox と HyperV にはその機能があるようです。

そして、そのついでにシェルスクリプトによる myvm のようなフレームワークの作り方やシェルプログラミングについて解説しました。シェルプログラミングの（もっと基本的な）書き方を知りたい場合は、OS にインストールされているコマンドを参照すると良いでしょう。意外と多くのシェルスクリプトで実装されたコマンドが `/bin` や `/usr/bin/` 以下に見つかるはずです。次のようなコマンドで探すことができます。

```console
$ file /usr/bin/* | grep shell
```

これらのコマンドのほとんどはシェル言語だけで実装されているわけではなく、他のコマンドを呼び出しているラッパーになっています。他のコマンドを組み合わせて使うためにシェルスクリプトが書かれています。そして **OS 標準のシェルスクリプトは対話シェルでのコマンドの書き写しではなく、しっかりとシェルプログラミングが行われている**ことがわかるはずです。そのコードは私が書いたmyvm のコードと似ていると感じるはずです。私のコードを参考にする必要はありませんが OS に含まれるシェルスクリプトは参考にしましょう。これがシェルスクリプトの一般的な書き方というやつです。