カイワレの大冒険 Third

技術的なことや他愛もないことをたまに書いてます

Chefのテストスイーツを色々試してみた (1)chefspec, minitestなどによるテスト整備

エンジニアのみなさま、自動化は進めていますでしょうか。海外ドラマにハマってる@masudaKです。

すたじおなんとかさんが、以下のようなことを仰ってまして、

rbenvのcookbookでも作ろうかなと思ったのですが、折角なので前から触りたかったツールを色々使ってみました。その紹介記事であります。

記事書こうかなと思ってるうちにVagrantがクリティカルに進化してたりして追いつけてない(近いうちに動かなくなる)部分もあるかもしれませんし、Rubyも触りしかわかってないので、間違い等あったらご指摘お願いします。

ちなみにChefあまり触ったことがない人は、chef-soloとかでまず簡単なところからやってみるのがよいと思うので、そのへんは他の記事に任せます。また、Chefサーバのインストールは以下の記事に書いてますので、そちらでも(古いので、陳腐化してるかも…)。

blog.masudak.net


今回の目的はテストや試験的環境を作り、そのなかでテスト・レシピを書いて、リリースするということを念頭に置いています。
ただ、Chefと戯れ続けてると以下の様な課題も出てきて、そういうのもできるだけクリアできるように書いたつもりです。

  • 本番で実際レシピ流したら、エラー吐いて、途中までしか流れてない。
  • エラーも吐かずレシピ流れたけど、実は違う環境(ステージングとか)の設定が流れてて、間違ったバージョンとかのもの流してる(気づきにくい)。テストがない。
  • 違うプラットフォームとかに向けてレシピ書いてるうちにデグレって、昔書いた処理がうまく機能してないことに気づく。
  • 色々書いてるうちに、コードが複雑化してしまう。リファクタしたいのにそのまんま…

まぁ、実際触れると色々詰まるのですが、そんなこと言ってもしょうがないので、気楽にレシピ修正して、自動化進めましょうという話しを書いてみたいと思います。

まずは、構成

構成をまず考えましょう。といっても、最初から文字で羅列すると複雑なので、Githubにあげた構成を見て下さい。
github.com

-   cookbooks/
    -   cookbooks/rbenv
        -   cookbooks/rbenv/spec
        -   cookbooks/rbenv/files/default/tests/minitest

    -   cookbooks/Rakefile

-   feature/

今回重要なのは、以上となります。一つ一つ説明していきますが、実装にかけられる時間とかを考えると、省いたほうがいい部分もあったりするので、この記事のなかで、これはやってみようかなというものだけ是非やってみてください。あと、間違ってる箇所があれば、是非ご指摘をば。
では、説明していきましょう。

cookbooks/rbenv/spec

早速レシピを書きたい気持ちはありますが、最初は良くても、後々テストがなく困るということが割りとあります。パッケージAはバージョン固定で、バージョン1.3を入れるようにして書いていたのに、違うプラットフォーム・ディストリビューションだとバージョンが変わっていて、処理書き換えていたら、なぜか違うバージョンが入ってるとか…

無駄に全環境に流して確認しないといけないとか、まぁ後々困ることはよくあります。

ので、そういうのを避けるためにテストを書きましょう。ここではRspecの記述を利用しながら、レシピのテストができる、chefspecを使ってみます。2013年3月20日時点ではChef11系では動かないので、rspec実行のchefのバージョンは上げ過ぎないように気をつけましょう。

基本的な書き方はドキュメントに書いてありますので、熟読しておくのがよいでしょう。書き方はシンプルで、gitパッケージのバージョン1.7.1-3.el6_4.1が入ったかどうかの確認をしたければ以下の様に書きます。

it { expect(chef_run).to install_package_at_version "git","1.7.1-3.el6_4.1" }

んで、さらにテスト全体を書く方法を説明します。ドキュメントにあるサンプルを少しいじって説明すると、

# まずrequire
require 'chefspec'

# describeでテストしたいレシピ記述して、convergeで引数にレシピを指定したものをletでメモ化。要はchef_runオブジェクトをずっと使いまわせるようにする
describe 'my_new_cookbook::default' do
  let(:chef_run) { ChefSpec::ChefRunner.new.converge 'my_new_cookbook::default' }
  # itを使って、どうなるかの記述をして、そのなかで/var/lib/fooディレクトリが作られていることを確認します。
  it 'should do something' do
    expect(chef_run).to create_directory '/var/lib/foo'
  end
end

概要はこんな感じです。シンプルなレシピであれば、このようなテストを書くのはそこまで難しくないでしょう。

あとは、 「$ rspec -fd -c spec/default_spec.rb」 でrspecを実行して、確認してみましょう。

レシピがまだないので、エラーになりますが、とりあえずそのまま必要そうな処理を書いていきます。

んで、僕の環境だと、OSの違いとかあってもちゃんとカバーできるようにテストを書こうとして、以下のようになっていきました。

describe 'my_new_cookbook::default' do
  let(:chef_run) { ChefSpec::ChefRunner.new.converge 'my_new_cookbook::default' }

  describe 'When Centos' do
    it 'should do something' do
      expect(chef_run).to create_directory '/var/lib/foo'
    end
  end

  describe 'When Ubuntu' do
    it 'should do something' do
      expect(chef_run).to create_directory '/var/lib/foo'
    end
  end
end

これだと、対象とするプラットフォーム増えるたびに、「create_directory '/var/lib/foo'」って処理が増えていくわけですね。

レシピに10の処理が書いてあるなら、10×プラットフォームの数になるわけです。んで、抽象クラスを作って、そのなかにそれを継承したcentosクラスとかubuntuクラスを作ってみましたが、それでも処理が長いことには変わらない。。。

また、テストである性質というのもあってファイル名は変えたくないので、どうしてもrecipes/default.rbに対するテストはspec/default_spec.rbに書いておきたいわけです。

しかも、あまりに疎にして、ファイルが分離しすぎたら、具体的であるはずのテストも見難くなってしまう。と思っていたら、いいものありました。

shared_exaplesという共通処理をまとめられるものが用意されてるのですね。
なので、パッケージをインストールするレシピに対応するテストは以下の様な感じにしてみました。

shared_examples 'Package' do |package_name, package_version|
  context 'when install' do
    it { expect(chef_run).to install_package_at_version "#{package_name}","#{package_version}" }
  end
end
<中略>
    describe "Package git" do
      it_behaves_like 'Package', 'git', '1.7.1-3.el6_4.1'
    end

こうすると、describeで文脈が変わっても、処理は共通化させることができる。しかも引数渡せるという神ぐあいで、助けられました。
あとは、OSの違いをパラメータとして渡さないといけないので、node['platform']にsetしないといけない。
そういう場合は、以下のようにnode.automatic_attrsを使って、値を渡します。

    let(:chef_run) {
      runner = ChefSpec::ChefRunner.new()
      runner.node.automatic_attrs['platform'] = 'centos'
      runner.converge 'rbenv::default'
      runner
    }

んで、最終的な形になったのが、以下。
github.com

書いてみて、以下の印象を受けました。

  • 共通化もできるし、処理がわかりやすい。モジュールとかにすれば、もっと共通化できるのかな。
  • 書き方もよくわからなかったけど、リファクタのポイントをまとめてくれる資料がわりとあって、少しずつ見やすくできた(気がする)
  • ただ、gitリソースとかに対応してなくて、テストできない部分があった
  • 実際にレシピ流さなくてもrspecコマンドだけでテストできるので、非常に早い。気楽にテストできる。

という感じです。Rubyよくわかってないので、今もいい書き方になってるかは分かりませんが、レシピのテストは最低限はできるし、一度形を決めれば使いまわせるので、初心者には優しいかなぁと。ただ、最低限で全てカバーできるわけでは当然ないので、そこは注意が必要です。

cookbooks/rbenv/files/default/tests/minitest

レシピのテストとしては上述したchefspecの他にminitestというものがあります。

chefと連携させたものとしてはminitest-chef-handlerになります。

minitestってtest-kitchenと連携させて、オプスタでインスタンスの作成とともにレシピを流して、その後レシピのテストを行うってのが一般的なのかなと思ってましたが、そうでもないみたいですね。

ということで、オプスタを使わず、Vagrantでやってみましょう。

www.ryuzee.com

をもとにやってみると、「$ bundle exec kitchen test」で簡単にCent6.4とかのテスト環境を作ってテストできました。

Vagrantが使える環境なら、test-kichenを使ってやってみるのが一番楽かと思います。ただ、Cent6.4のVMはできたのですが、Ubuntuのとあるboxでは以下の様なエラーでテストできませんでした。

vagrant-1.0.7/lib/vagrant/action/vm/customize.rb:23:in `block in call': A customization command failed: (Vagrant::Errors::VMCustomizationFailed)

["storagectl", "37f52d42-6398-4c79-b79f-83bf49571509", "--name", "SATA Controller", "--hostiocache", "on"]

The following error was experienced:

VBoxManage: error: Could not find a storage controller named 'SATA Controller'
VBoxManage: error: Details: code VBOX_E_OBJECT_NOT_FOUND (0x80bb0001), component SessionMachine, interface IMachine, callee nsISupports
VBoxManage: error: Context: "GetStorageControllerByName(Bstr(pszCtl).raw(), ctl.asOutParam())" at line 1151 of file VBoxManageStorageController.cpp
VBoxManage: error: Couldn't find the controller with the name: 'SATA Controller'

こっちを直してもいいのですが、元々Vagrant環境はあったので、そっちで流せるようにしてみました。
やりかたは、自分がテストしたいcookbookがあるディレクトリに、

$ git clone https://github.com/btm/minitest-handler-cookbook.git
$ rm -rf minitest-handler-cookbook/.git

してみましょう。んで、必要であればgitignoreでレポジトリ管理下にいれないようにする。

あとは、ドキュメントにあるように"minitest-handler"をrun_listに加えるのですが、僕の場合はvagrant環境があるので、Vagrantfileに以下のように記述します。

   config.vm.provision :chef_solo do |chef|
     chef.cookbooks_path = "../masudaK-chef/cookbooks"
     chef.add_recipe "minitest-handler"
     chef.add_recipe "rbenv"
   end

これでminitestが「$ vagrant provision」のあとに走るようになります。

次に書き方を。
http://cookbooks/rbenv/files/default/tests/minitest/default_test.rb
を見て頂ければわかりますが、そこまでchefspecと乖離するほどの内容ではありません。

  include MiniTest::Chef::Assertions
  include MiniTest::Chef::Context
  include MiniTest::Chef::Resources

という記述をして、

  it 'installs git' do
    package('git').must_be_installed.with(:version, "1.7.1-3.el6_4.1")
  end

こんな感じで指定するだけです。ただ、chefspecと同じように、OSの違いとかを吸収したくなったわけです。
んで、書いてみたのがこちら。

require 'minitest/spec'

describe 'rbenv' do
  include MiniTest::Chef::Assertions
  include MiniTest::Chef::Context
  include MiniTest::Chef::Resources

  case "#{node['platform']}" # node attribute would be get inside it block...
  when "centos"

    it 'installs git' do
      package('git').must_be_installed.with(:version, "1.7.1-3.el6_4.1") # minitest-chef-handler
    end
    it "Directory dir" do
      file("/home/vagrant/.rbenv/plugins").must_exist.with(:owner, "vagrant").and(:group, "vagrant").and(:mode, "0755")
    end


  when "ubuntu"
    it 'installs git' do
      package('git').must_be_installed.with(:version, "1:1.7.10.4-1ubuntu1")
    end
    it "Directory dir" do
      file("/home/vagrant/.rbenv/plugins").must_exist.with(:owner, "vagrant").and(:group, "vagrant").and(:mode, "0755")
    end
  end
end

これ残念ながら、動きません…
コメントにも書いてありますが、nodeアトリビュートが使えるのはitブロックのなかだけなので、以下のようにしないといけないんですね。

    it 'installs git' do
      case "nodeほにゃらら"
      when "centos"
        package('git').must_be_installed.with(:version, "1.7.1-3.el6_4.1") # minitest-chef-handler
      when "ubuntu"
      ....
    end

これだと、当然冗長になってしまうわけです。ので、解決策募集中ですが、ファイル分ける以外でできるんだろうか…
ひとまず、使ってみた感想としては以下。

  • 書き方自体はrspec同様複雑ではない
  • 同時に複数のVMでテストできるので、ディレクトリの移動とかなくて楽
  • サーバに入って目で確認できるタイプ
  • がっつりレシピ流してからテストするので、ごっつい
  • nodeアトリビュートいじれない…

という感じでしょうか。現状chefspecのほうが僕にはあってる気がします。ただ、リソースの対応とかも増えてきたら、ちょっと考えるかもという状況です。いずれにしても、そこまで大きな書き方の差異はないですし、もっと柔軟にかけるかもなので、実は違うのかもしれません。

テスト・レシピを書く

とりあえず、テストは最低限書く環境ができました。rspecでもminitestでもどちらでもいいですが、テストを書きましょう。
項目としては、以下の様なイメージ。

  • rbenvを入れるための必要最低限環境を整える。パッケージいれたり、ディレクトリ作ったり。
  • rbenvをclone
  • ruby-buildをclone
  • .bashrcを書き換え
  • attributeに渡せるものは渡す。

という感じでしょうか。本当は.bashrcと.zshrcが選べたり、インストールのディレクトリを変更できたり、まぁ色々できたほうがいいのでしょうが、ひとまずシンプルに書くことを念頭に置きます。
したがって、テストは、

  • rbenvを入れるために必要なパッケージが入ってるかどうか(ex: git cloneするためにはgitパッケージが必要)
  • 必要なディレクトリが作られてるかどうか(ex: ruby-buildを入れるためには$HOME/.rbenv/pluginsディレクトリが必要)
  • .bashrcに必要な処理が入ってるか(ex: パスの追加とかが必要)

となるでしょう。 んで、書いたテストは前に示したように以下。
github.com

んで、それに合わせてレシピを書きます。 レシピは以下。
github.com

あとは、以下でテストします。

$ rspec -fd -c spec/default_spec.rb

出力は以下の様な感じ。

rbenv::default
  on a CentOS box
    Package git
      behaves like Package
        when install
          should install package at version "git" and "1.7.1-3.el6_4.1"
    Directory ruby-build
      behaves like Directory
        when create
          should create directory "/home/vagrant/.rbenv/plugins"
          should be owned by "vagrant" and "vagrant"
          should == 493
    Template .bashrc
      behaves like Template
        when create
          should create file with content "/home/vagrant/.bashrc" and "rbenv"
  on a Ubuntu box
    Package git
      behaves like Package
        when install
          should install package at version "git" and "1:1.7.10.4-1ubuntu1"
    Directory ruby-build
      behaves like Directory
        when create
          should create directory "/home/vagrant/.rbenv/plugins"
          should be owned by "vagrant" and "vagrant"
          should == 493
    Template .bashrc
      behaves like Template
        when create
          should create file with content "/home/vagrant/.bashrc" and "rbenv"

Finished in 0.07047 seconds
10 examples, 0 failures

できるだけ自然言語として意味が通るように作ってみましたが、どうだろうか…

この辺はbest practiceを紹介してるここ
betterspecs.org
とかを見ながら、直していくのがよいでしょう。

一応、minitestにも対応してるので、vagrant provisionすればテストできますが、ubuntuの場合は、以下のように当然怒られてしまうので、ここは課題だったりします。

Finished tests in 0.492544s, 8.1211 tests/s, 8.1211 assertions/s.

1) Failure:
test_0001_installs_git(rbenv) [/var/lib/gems/1.9.1/gems/minitest-chef-handler-0.6.8/lib/minitest-chef-handler/resources.rb:41]:
The package does not have the expected version.
Expected: "1.7.1-3.el6_4.1"
Actual: "1:1.7.10.4-1ubuntu1"

終わりに

ひとまず、ここまではいかにテストを書いていくかを紹介してみました。

どう環境作るか分からないうちは謎なことが多かったですが、分かってくるとそこまでテストも複雑ではありませんし、いろんなOSの空きサーバ用意するのも大変なので、テスト書いてみようかなという気になってます。そういう環境がないと、なんか謎のバージョンとか入っちゃったりしてるんですよね。

ということで、レシピのテストはあるといいよというテンションで今回は終わらせたいと思います。次回の記事では、ここまで書いたレシピやテストをいかにリファクタしていくのかを紹介してみたいと思います。

次回作はこちら。
blog.masudak.net