vagrant-mutagen を fork して公開したときに学んだ Vagrant Plugin の作り方


# vagrant-mutagen を fork して公開したときに学んだ Vagrant Plugin の作り方

## はじめに

vagrant3.0 以降は Vagrant が Go で実装されるとのことなので plugin の作り方は変わると思われます。([参考](https://www.hashicorp.com/blog/toward-vagrant-3-0))

Vagrant Plugin は Vagrant に機能を追加することができる拡張機能です。

最近では VM を起動する際にファイル同期をする際に mutagen を使うようにしており、VM の起動/停止と共に mutagen project を連動させる [vagrant-mutagen plugin](https://github.com/dasginganinja/vagrant-mutagen) を使おうと考えていました。

しかし、Windows ではまともに動かず、[PR](https://github.com/dasginganinja/vagrant-mutagen/pull/7) を出しつつ修正したものを使っていたのですが、それでも VM を起動するたびに 2 回も管理者権限を昇格する必要があり困っていました。

いっそ fork して、思うままに Vagrant Plugin を作ることにし、その際に調べたことをまとめることとしました。

fork した plugin のソースコードは[こちら](https://github.com/ryu-sato/vagrant-mutagen-utilizer)にあるので、1例として参考にしてみてください。

## Vagrant Plugin 概要

* Vagrant は Ruby プログラムであり、Vagrant plugin は ruby の gem です
* rubygems に公開してあると vagrant plugin install 実行時に検索されます
    * plugin の名前は慣例で `vagrant-XXX` という名前でにしているようです

作り方の概要は次に記載されているものを参考にしました。

https://www.vagrantup.com/docs/plugins/development-basics


# Gemfile の書き方

`my-vagrant-plugin` が自分で作成する Plugin の名前です。

```ruby
source "https://rubygems.org"

group :development do
  gem "vagrant", git: "https://github.com/hashicorp/vagrant.git"
end

group :plugins do
  gem "my-vagrant-plugin", path: "."
end
```

開発用に Vagrant が必要となり、開発モードでは `vagrant plugin` コマンドは機能せず代わりに plugins グループに指定された gem が読み込まれるとのことです。

普段 Windows 環境には Ruby 開発を用意していなかったため、Vagrant plugin を開発するにあたり、Vagrant は Windows にインストール済みのものを使い、Plugin を都度 `gem build` して vagrant で uninstall/install していました。

1. Plugin を gem にする
    * `gem build ＜SOME_PLUGIN_NAME＞.gemspec` を実行すると `＜SOME_PLUGIN_NAME＞-X.Y.Z.gem` が保存されます
1. Plugin をインストールする
    * `vagrant plugin install ＜PATH_TO_SOME_PLUGIN＞/＜SOME_PLUGIN_NAME＞-X.Y.Z.gem` により Plugin がインストールされます


(大した修正はしないと思っていたのですが、結果的に振り返ると Vagrant を開発モードで動作させた方が楽だったように感じます)


# Plugin の基本設計

Plugin の本体となるクラスを定義し、クラス内で Config, Provisioner などのコンポーネントクラス名を指定します。

gemspec に記載された name に対応するファイルから Plugin クラスを require する必要があります。

```ruby:lib/＜SOME_PLUGIN_NAME＞/plugin.rb
class MyPlugin < Vagrant.plugin("2")
  name "My Plugin"

  command "run-my-plugin" do
    require_relative "command"
    Command
  end

  provisioner "my-provisioner" do
    require_relative "provisioner"
    Provisioner
  end
end
```

作成した vagrant-mutagen-utilizer では次のようになりました。

```ruby:lib/vagrant_mutagen_utilizer/plugin.rb
# frozen_string_literal: true

require_relative 'action/update_config'
require_relative 'action/remove_config'
require_relative 'action/start_orchestration'
require_relative 'action/terminate_orchestration'
require_relative 'action/save_machine_identifier'

module VagrantPlugins
  module MutagenUtilizer
    # Plugin to utilize mutagen
    class MutagenUtilizerPlugin < Vagrant.plugin('2')
      name 'Mutagen Utilizer'
      description <<-DESC
        This plugin manages the ~/.ssh/config file for the host machine. An entry is
        created for the hostname attribute in the vm.config.
      DESC

      config(:mutagen_utilizer) do
        require_relative 'config'
        Config
      end

      action_hook(:mutagen_utilizer, :machine_action_up) do |hook|
        hook.append(Action::UpdateConfig)
        hook.append(Action::StartOrchestration)
      end
        : <snip>
    end
  end
end
```

```ruby:vagrant-mutagen-utilizer.gemspec
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'vagrant_mutagen_utilizer/version'

Gem::Specification.new do |spec|
  spec.name          = 'vagrant-mutagen-utilizer'
    : <snip>
end
```

```ruby:lib/vagrant-mutagen-utilizer.rb
# frozen_string_literal: true

require 'vagrant_mutagen_utilizer/version'
require 'vagrant_mutagen_utilizer/plugin'

module VagrantPlugins
  # MutagenUtilizer
  module MutagenUtilizer
    def self.source_root
      @source_root ||= Pathname.new(File.expand_path('..', __dir__))
    end
  end
end
```

fork した vagrant-mutagen がもともとそうなっていたのですが、Plugin 本体には様々な処理は記述せずに、処理ごとにクラスを分けて記述して require_relative を使って読み込み、コンポーネントとして登録しています。


## Config の設定方法

Plugin 本体側にクラス名を指定します。
ここで指定する "foo" が Vagrantfile の config.`foo`.～ に当たります。

```ruby
config "foo" do
  require_relative "config"
  Config
end
```

Config コンポーネントは `Vagrant.plugin(2, :config)` のサブクラスとして定義します。

```ruby
class Config < Vagrant.plugin(2, :config)
  attr_accessor :widgets

  def initialize
    @widgets = UNSET_VALUE
  end

  def finalize!
    @widgets = 0 if @widgets == UNSET_VALUE
  end
end
```

UNSET_VALUE は Vagrant における未定義を指す値です。
Plugin によっては nil を初期値や正しい値として定義できるよう、未定義とする値が用意されています。

initialize は Config クラスが初期化されるときのコンストラクタで、finalize! はすべての Config が読み終わった時に呼び出されます。

例では `@widgets` が Config ファイルで定義されなかった場合は `0` を代入しています。

Config で設定された値は、`machine.config.mutagen_utilizer.orchestrate` のようにアクセスできます。

## Action Hooks

`vagrant up`, `vagrant halt` などが実行されたときに特定の処理をしたい場合に設定します。

Hook の種類は https://www.vagrantup.com/docs/plugins/action-hooks#public-action-hooks に書かれています。

以下では、`vagrant up` が実行されたときに UpdateConfig, StartOrchestration クラスを call するよう設定しています。

```ruby:lib/vagrant_mutagen_utilizer/plugin.rb
# frozen_string_literal: true

require_relative 'action/update_config'
require_relative 'action/start_orchestration'
        : <snip>

module VagrantPlugins
  module MutagenUtilizer
    # Plugin to utilize mutagen
    class MutagenUtilizerPlugin < Vagrant.plugin('2')
        : <snip>
      action_hook(:mutagen_utilizer, :machine_action_up) do |hook|
        hook.append(Action::UpdateConfig)
        hook.append(Action::StartOrchestration)
      end
        : <snip>
    end
  end
end
```

UpdateConfig, StartOrchestration クラスは次のように定義されています。

```ruby:lib/vagrant_mutagen_utilizer/action/update_config.rb
# frozen_string_literal: true

require_relative '../orchestrator'

module VagrantPlugins
  module MutagenUtilizer
    module Action
      # Update ssh config entry
      # If ssh config entry already exists, just entry appended
      class UpdateConfig
        def initialize(app, env)
          @app = app
          @machine = env[:machine]
          @config = env[:machine].config
          @console = env[:ui]
        end

        def call(env)
          return unless @config.orchestrate?

          o = Orchestrator.new(@machine, @console)
          o.update_ssh_config_entry
          @app.call(env)
        end
      end
    end
  end
end
```

```ruby:lib/vagrant_mutagen_utilizer/action/start_orchestration.rb
# frozen_string_literal: true

require_relative '../orchestrator'

module VagrantPlugins
  module MutagenUtilizer
    module Action
      # Start mutagen project
      class StartOrchestration
        def initialize(app, env)
          @app = app
          @machine = env[:machine]
          @config = env[:machine].config
          @console = env[:ui]
        end

        def call(env)
          return unless @config.orchestrate?

          o = Orchestrator.new(@machine, @console)
          o.start_orchestration
          @app.call(env)
        end
      end
    end
  end
end
```

UpdateConfig, StartOrchestration どちらも、コンストラクタでは引数から受け取った値をインスタンス変数に登録しているのみです。

- `@app`: call メソッドが実行されたときに使用する。詳細は不明。
- `@machine`: VMに関するIDやConfigが参照できる。
- `@config`: 設定が参照できる
- `@console`: コンソール入出力ができる

メインの機能は call メソッドに書かれています。
ここでは Config に Plugin が有効であるか調べ、有効になっていたら Orchestrattor クラスの start_orchestration メソッドを呼び出しています。

Orchestrattor クラスは次のとおりです。

```ruby
# frozen_string_literal: true

module VagrantPlugins
  module MutagenUtilizer
    # Class for orchestrate with mutagen
    class Orchestrator
      def initialize(machine, console)
        @machine = machine
        @console = console
      end

      # Update ssh config entry
      # If ssh config entry already exists, just entry appended
      def update_ssh_config_entry
        hostname = @machine.config.vm.hostname

        logging(:info, 'Checking for SSH config entries')
        if ssh_config_entry_exist?
          logging(:info, "  updating SSH Config entry for: #{hostname}")
          remove_from_ssh_config
        else
          logging(:info, "  adding entry to SSH config for: #{hostname}")
        end
        append_to_ssh_config(ssh_config_entry)
      end

      def start_orchestration
        return if mutagen_project_started?

        logging(:info, 'Starting mutagen project orchestration (config: /mutagen.yml)')
        start_mutagen_project || logging(:error, 'Failed to start mutagen project (see error above)')
        # show project status to indicate if there are conflicts
        list_mutagen_project
      end

        : <snip>

      private

        : <snip>
    end
  end
end
```

詳細は割愛していますが、Action Hooks から呼び出される update_ssh_config_entry は SSH 設定ファイルにエントリーを追加する処理、start_orchestration は mutagen project を開始する処理がそれぞれ記述されています。

このようにして、`vagrant up` が実行されたときに特定の処理を行う Plugin が書けます。

`vagrant up` 以外のフィルタが知りたい場合は https://www.vagrantup.com/docs/plugins/action-hooks#public-action-hooks を参考にするとよいと思います。


## コンソール入出力

既に何度か登場していますが、Vagrant の I/O 用の `Vagrant::UI` を介してテキストを入出力します。
Ruby 標準の put/get を使うことができません。

各 middleware の Environment である `env` により `env[:ui]` で Vagrant::UI を取得できます。

ログの出力レベルが info, warn, error, success とあり、それぞれのレベルでログを出力できます。


## Plugin の公開

gem を rubygems に公開する手順と同じです。

### rake コマンドを使う場合 (Vagrant 公式で楽な手段であると紹介されている方法)

1. bundle install
2. rake build
3. rake release

### gem コマンドを使う場合

1. gem build 
2. gem push