Itamaeが構成管理を仕込みます! ~新進気鋭の国産・構成管理ツール~

第4回レシピプラグイン、リソースプラグインの作り方

前回はノード属性を使って汎用的なレシピを書く方法を紹介しました。汎用的なレシピは様々な環境で利用できるので、レシピをプラグインとして公開しておくことで再利用できます。また、プラグインとして独自のリソースを定義し、レシピを簡潔に書けます。

プラグイン

Itamaeにはプラグイン機構が用意されていて、汎用的なレシピを公開したり、独自のリソースを実装することができます。プラグインはRubygemとして公開できるようになっていて、2015年8月24日(月)現在、33個のプラグインが存在します。プラグインは単なるGemなので、Bundlerを使って依存関係を管理できます。

レシピプラグイン

レシピプラグインを作ることで、レシピを再利用できる形で公開できます。レシピプラグインGemの名前はitamae-plugin-recipe-(レシピ名)の形式にする必要があります。rubygems.orgitamae-plugin-recipeを検索すると既存のプラグインを探すことができます。

ここでは例としてnginx用のレシピプラグインを作ってみたいと思います。まず、bundle gemコマンドを使って、Gemのひな形を生成します。

$ bundle gem itamae-plugin-recipe-nginx

gemspecファイルにTODOが含まれていると、Gemのインストールで失敗するので、修正しておきます。

レシピはlib/itamae/plugin/recipe/nginx/default.rbに書くことで、他のレシピからinclude_recipe "nginx"で読み込むことができます。また、複数のレシピを使い分けて読み込みたい場合は、lib/itamae/plugin/recipe/nginx/proxy.rbのように同じディレクトリにレシピを配置すると、include_recipe "nginx::proxy"またはinclude_recipe "nginx/proxy"で読み込むことができます。

lib/itamae/plugin/recipe/nginx/default.rb:
node.reverse_merge!(
  nginx: {
    user: 'www-data',
    worker_processes: 4,
  }
)

package 'nginx' do
  version node['nginx']['version'] if node['nginx']['version']
end

service 'nginx'

template '/etc/nginx/nginx.conf' do
  owner 'root'
  group 'root'
  mode '644'
  notifies :reload, 'service[nginx]'
end
lib/itamae/plugin/recipe/nginx/templates/etc/nginx/nginx.conf:
user <%= node['nginx']['user'] %>;
worker_processes <%= node['nginx']['worker_processes'] %>;
pid /run/nginx.pid;

events {
  worker_connections 768;
}

http {
  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  keepalive_timeout 65;
  types_hash_max_size 2048;

  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  access_log /var/log/nginx/access.log;
  error_log /var/log/nginx/error.log;

  gzip on;
  gzip_disable "msie6";

  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-enabled/*;
}

レシピやテンプレートの書き方は通常のレシピと同様です。できるだけ様々なシーンで利用できるように、nodeの値を参照して柔軟に設定できるようにしておくことをお勧めします。このレシピが期待通り動くかどうかをServerspecでテストしましょう。Serverspecはサーバの状態を記述して想定通りの状態になっているかどうかをテストするためのフレームワークです。

まず、gemspecにServerspecへの依存を記述します。

   spec.add_development_dependency "bundler", "~> 1.10"
   spec.add_development_dependency "rake", "~> 10.0"
   spec.add_development_dependency "rspec"
+  spec.add_development_dependency "serverspec"
 end

テストはVagrantで起動した仮想マシンに対して実行するので、Vagrantfileを生成します。なお、Vagrantのインストールについては公式ドキュメントを参照してください。

$ vagrant init -m ubuntu/trusty64

Serverspec用の設定を書きます。spec/spec_helper.rbに以下を記述します。

$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)

require 'serverspec'
require 'net/ssh'
require 'tmpdir'

set :backend, :ssh

if ENV['ASK_SUDO_PASSWORD']
  begin
    require 'highline/import'
  rescue LoadError
    fail "highline is not available. Try installing it."
  end
  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
  set :sudo_password, ENV['SUDO_PASSWORD']
end

# Vagrantを使って仮想マシンを起動します
system "vagrant", "up", "default"

ssh_options = nil
config = Tempfile.new("itamae")
config.write(`vagrant ssh-config default`)
config.close
ssh_options = Net::SSH::Config.for("default", [config.path])
config.unlink

ssh_options[:user] ||= Etc.getlogin

set :host,        ssh_options[:host_name]
set :ssh_options, ssh_options

Serverspecのテストを記述します。

require 'spec_helper'
require 'itamae'

describe Itamae::Plugin::Recipe::Nginx do
  before(:all) do
    Itamae.logger.level = ::Logger::INFO

    # SSHでVagrantのホストに対してItamaeを実行します
    backend = Itamae::Backend.create("ssh", {vagrant: true, sudo: true})
    backend.run_command(%w!apt-get update!)

    runner = Itamae::Runner.new(backend, {})
    runner.load_recipes([File.expand_path("lib/itamae/plugin/recipe/nginx/default.rb")])
    runner.run(dry_run: false)
  end

  # nginxパッケージがインストールされていること
  describe package('nginx') do
    it { is_expected.to be_installed }
  end

  # nginxサービスが実行中であること
  describe service('nginx') do
    it { is_expected.to be_running }
  end

  # nginxのワーカプロセスが4つ起動していること
  describe command('ps aux | grep [n]ginx | grep worker | wc -l') do
    its(:stdout) { is_expected.to eq("4\n") }
  end
end

BundlerでServerspecをインストールし、テストを実行します。

$ bundle install
$ bundle exec rake spec
(中略)
Itamae::Plugin::Recipe::Nginx
 INFO : Recipe: /Users/ryota-arai/src/github.com/ryotarai/itamae-plugin-recipe-nginx/lib/itamae/plugin/recipe/nginx/default.rb
  Package "nginx"
    should be installed
  Service "nginx"
    should be running
  Command "ps aux | grep [n]ginx | grep worker | wc -l"
    stdout
      should eq "4\n"

Finished in 16.68 seconds (files took 7.82 seconds to load)
3 examples, 0 failures

ItamaeがVagrantの仮想マシンに実行された後、テストが実行されます。プラグインをリリースする際は通常のrubygemと同様、rake releaseでリリースします。

$ bundle exec rake release

リソースプラグイン

続いて、リソースプラグインの作り方を解説します。レシピプラグインと同様にリソースプラグインのGemの名前はitamae-plugin-resource-(リソース名)にします。ここでは例としてcronリソースを作ってみます。レシピプラグインと同様にbundle gemコマンドでひな形を作成します。

$ bundle gem itamae-plugin-resource-cron

lib/itamae/plugin/resource/cron.rbを編集してリソースを実装します。まず、アクションの実行前に実行される部分を書いてみます。

require "itamae"

module Itamae
  module Plugin
    module Resource
      class Cron < ::Itamae::Resource::Base
        class Error < StandardError; end

        # define_attributeでリソースで使える属性を宣言します
        # デフォルトのアクションはcreate
        define_attribute :action, default: :create
        define_attribute :minute, type: String, default: '*'
        define_attribute :hour, type: String, default: '*'
        define_attribute :day, type: String, default: '*'
        define_attribute :month, type: String, default: '*'
        define_attribute :weekday, type: String, default: '*'
        define_attribute :cron_user, type: String, default: 'root'
        define_attribute :command, type: String
        # default_name: trueを指定すると、その属性が指定されなかった場合にリソース名が使われます
        define_attribute :cron_name, type: String, default_name: true

        # pre_actionはアクション前に実行されるメソッドです
        def pre_action
          case @current_action
          when :create
            # createアクションの場合、cronリソースが存在する(exist == true)状態になることが期待されている
            attributes.exist = true
          when :delete
            # deleteアクションの場合、cronリソースが存在しない(exist == false)状態になることが期待されている
            attributes.exist = false
          end
        end

        # set_current_attributesでホスト上の現在の状態を設定します(アクション前に実行される)
        def set_current_attributes
          if run_specinfra(:check_file_is_file, cron_file)
            current.exist = true

            # 既存のcron設定をパースする
            fields = parse_crontab(backend.receive_file(cron_file))
            current.minute = fields[:minute]
            current.hour = fields[:hour]
            current.day = fields[:day]
            current.month = fields[:month]
            current.weekday = fields[:weekday]
            current.cron_user = fields[:cron_user]
            current.command = fields[:command]
          else
            current.exist = false
          end
        end

        private

        def cron_file
          key = attributes.cron_name.gsub(%r{(\s+|/)}, '-')
          "/etc/cron.d/itamae-#{key}"
        end

        def parse_crontab(crontab)
          line = crontab.each_line.find {|l| !l.start_with?('#') }
          r = line.chomp.match(/\A([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+(.+)\z/)
          unless r
            raise Error, "Invalid crontab format."
          end

          {minute: r[1], hour: r[2], day: r[3], month: r[4], weekday: r[5],
           cron_user: r[6], command: r[7]}
        end
      end
    end
  end
end

基本的な動作はコード内のコメントに記述しましたが、リソースの処理の基本的な流れとしては、

  1. set_current_attributesでホスト上の現在のリソースの状態(current)を読み込む
  2. pre_actionでその他の処理を実行する
  3. current(現在の状態)attributes(設定したい状態)の差を埋める

といった形です。currentattributesを適切に設定しておくと、実行時に何が変更されるのか、変更前と変更後の値が表示されます。また、currentattributesに差がある場合のみ、アクション実行後にnotifiessubscribesに指定したアクションが実行されます。

実際のアクションはaction_(アクション名)メソッドに実装します。cronリソースの場合は以下のように実装しました。

module Itamae
  module Plugin
    module Resource
      class Cron < ::Itamae::Resource::Base
        # (中略)

        def action_create(options)
          # ローカルの一時ファイルにcron定義を書く
          f = Tempfile.open('itamae')
          f.write(generate_cron)
          f.close

          # 対象サーバの一時ファイルに送信
          temppath = ::File.join(runner.tmpdir, Time.now.to_f.to_s)
          # send_fileを呼ぶと、ローカルから対象サーバにファイルが転送されます
          # (例えば、SSH実行の場合、SCPで送信されます)
          backend.send_file(f.path, temppath)

          # 一時ファイルを動かす
          # (一旦、一時ファイルに書いているのは、アトミックにファイルを更新するため)
          run_specinfra(:move_file, temppath, cron_file)
        ensure
          f.unlink if f
        end

        def action_delete(options)
          if current.exist
            run_specinfra(:remove_file, cron_file)
          end
        end

        private

        def generate_cron
          <<-EOCRON
# DO NOT EDIT THIS MANUALLY
# BECAUSE THIS IS AUTO GENERATED BY Itamae
#{attributes.minute} #{attributes.hour} #{attributes.day} #{attributes.month} #{attributes.weekday} #{attributes.cron_user} #{attributes.command}
          EOCRON
        end

        # (中略)
      end
    end
  end
end

ここまで何回か登場したrun_specinfraSpecinfraのコマンドを実行するためのメソッドです。ItamaeはSpecinfraの上で動く設計になっていて、run_specinfraを呼ぶと対象サーバのOSなどを判定して適切なコマンドを実行してくれます。例えば、install_packageを呼ぶとUbuntuではapt-get installRHELではyum installを実行します。この仕組みを利用することでItamae側にはOSごとの分岐をほぼ書かずに実装ができています。実際にどういったコマンドが利用できるかは、Specinfraのドキュメントやコードを参照してみてください。

では、ここまで実装したcronリソースを使ってみます。適当なディレクトリを用意して、Gemfileとサンプルレシピを作成して、

# Gemfile

source "https://rubygems.org"
gem "itamae-plugin-resource-cron", path: "/path/to/itamae-plugin-resource-cron"
# example.rb
cron 'say hello' do
  minute "10"
  command 'echo Hello'
end

Bundlerを使って実行します。

$ bundle install
$ bundle exec itamae local example.rb
(略)
$ cat /etc/cron.d/itamae-say-hello

/etc/cron.dの下にファイルができていれば成功です。なお、今回実装したcronリソースのコードはGitHubにあります

まとめ

今回はレシピをプラグインとして再利用可能な形で公開する方法、そして、Itamaeに用意されていない処理を独自リソースで行う方法を紹介しました。ぜひ、プラグインを開発して公開していただければと思います。

おすすめ記事

記事・ニュース一覧