AWS SDK for RubyでCLIツールを作った時の作業メモ

AWS SDK for Rubyを使ってちょっとしたCLIツールを作ったのでその時の作業メモ。(実際には色々もっと処理があるのですが、そこは抜粋)

実施したことは以下になります。

+ エラーハンドリングを行い、適切なエラーを表示する
+ 引数を処理する
+ ログを表示する

Rubyについて詳しいわけではないので、こうやった方がもっといい感じに書けるなどあればコメントをお願いいたします!

# 準備

```bash
$gem install aws-sdk
```

# AccessKey,SecretyKeyの設定

AWS SDKを使うためにAccessKey,SecretyKeyを用意します。

[AWS SDK for Ruby - Version 2-configuration](http://docs.aws.amazon.com/sdkforruby/api/index.html#Configuration)

## EC2上でコードを実行する場合

利用したいAPIの許可が設定されたIAMロールを作成します。

また、コードを実行したいEC2を起動する際に上記で作成したIAMロールを設定します。

そうすることで特にコードで設定しなくても自動的にIAMロールの情報を使います。（AWS CLIなども同様）

## EC2以外でコードを実行する場合

いくつか方法があります。

+ ENV['AWS_ACCESS_KEY_ID'] and ENV['AWS_SECRET_ACCESS_KEY']
+ The shared credentials ini file at ~/.aws/credentials (more information)

環境変数で設定するか、`~/.aws/credentials`にキー情報を配置する方法があります。

また、コードに直接書くのも可能ですが、このままコミットなどすると危険なので、上記いずれかが良いと思います。

```rb
Aws.config.update({
  region: 'us-west-2',
  credentials: Aws::Credentials.new('akid', 'secret'),
})
```

# 触ってみる

とりあえず、特定のIDのRoute53のホスト情報を取得してみます。基本的な使い方としてはサービス(EC2やRoute53など）のclientクラスを作成して、そこから生えているメソッド群を使って操作してく感じになります。

取得方法は公式APIドキュメントで確認します。

[Class: Aws::Route53::Client](http://docs.aws.amazon.com/sdkforruby/api/Aws/Route53/Client.html)

```rb
require 'aws-sdk-core'

REGION = 'ap-northeast-1'
id = '11111111111'

client = Aws::Route53::Client.new(region: REGION)

resp = client.get_hosted_zone({
  id: id,
})

p resp
```

最後に返却されたものをpメソッドで表示するようにしています。

実行してみます。

```bash
$ruby route53.rb

#<Aws::PageableResponse:0x007000000000 @pager=#<Aws::Paging::NullPager:0x000000000>, @context=#<Seahorse::Client::RequestContext:0x007fde0000000 @operation_name="get_hosted_zone", @operation=#<Seahorse::Model::Operation:0x007f000000 @definition={"name"=>"GetHostedZone", "http"=>{"method"=>"GET", "requestUri"=>"/2013-04-01/hostedzone/{Id}"}, "input"=>{"shape"=>"GetHostedZoneRequest"}, "output"=>{"shape"=>"GetHostedZoneResponse"}, "errors"=>[{"shape"=>"NoSuchHostedZone", "error"=>{"httpStatusCode"=>404}, "exception"=>true}, {"shape"=>"InvalidInput", "error"=>{"httpStatusCode"=>400}, "exception"=>true}]}, @shape_map=#<Seahorse::Model::ShapeMap:0x007fdeb12c2300 @definitions={"AliasHealthEnabled"=>{"type"=>"boolean"}, "AliasTarget"=>{"type"=>"structure", "required"=>["HostedZoneId", "DNSName", "EvaluateTargetHealth"], "members"=>{"HostedZoneId"=>{"shape"=>"ResourceId"}, "DNSName"=>{"shape"=>"DNSName"}, "EvaluateTargetHealth"=>{"shape"=>"AliasHealthEnabled"}}}, "AssociateVPCComment"=>{"type"=>"string"}, "AssociateVPCWithHostedZoneRequest"=>{"type"=>"structure", "required"=>["HostedZoneId", "VPC"], "members"=>{"HostedZoneId"=>{"shape"=>"ResourceId", 
```

**???**

想定した感じでない非常に見づらいものが取れてしまいました。。。
上記だと何がなんやら分からないので `pp` を使って取ります。また何のクラスが取れるのか確認します。ppについては以下を参考にしました。

[Ruby で debug する7つの方法](http://secondlife.hatenablog.jp/entry/20061010/1160453355)

```rb
require 'aws-sdk-core'
require 'pp'

REGION = 'ap-northeast-1'
id = '111111111111'

client = Aws::Route53::Client.new(region: REGION)

resp = client.get_hosted_zone({
  id: id,
})

p resp.class
pp resp
```

実行してみます。

```bash
$ruby route53.rb

Aws::PageableResponse
#<struct
 hosted_zone=
  #<struct
   id="/hostedzone/11111111111",
   name="hoge.local.",
   caller_reference="AAAAAAAAAAAAAAAAAA",
   config=#<struct  comment="test", private_zone=true>,
   resource_record_set_count=2>,
 delegation_set=nil,
 vp_cs=[#<struct  vpc_region="ap-northeast-1", vpc_id="vpc-111111">]>
```

非常にいい感じに表示されました!

返却では`PageableResponse`クラスが返却され、中身には取得したHostedZoneの情報が取得できているのが確認できました。nameとなっている部分は、`resp.hosted_zone.name`とすれば取得できます。

# 例外を取得してメッセージを表示する

上記はIDに指定したhostedzoneが存在したので問題ありませんでしたが、存在しない場合、エラーとなります。

```bash
$ruby route53.rb
/home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/seahorse/client/plugins/raise_response_errors.rb:15:in `call': No hosted zone found with ID: 1111111111111111 (Aws::Route53::Errors::NoSuchHostedZone)
        from /home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/seahorse/client/plugins/param_conversion.rb:22:in `call'
        from /home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/aws-sdk-core/plugins/response_paging.rb:10:in `call'
        from /home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/seahorse/client/plugins/response_target.rb:18:in `call'
        from /home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/seahorse/client/request.rb:70:in `send_request'
        from /home/vagrant/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/aws-sdk-core-2.0.39/lib/seahorse/client/base.rb:216:in `block (2 levels) in define_operation_methods'
        from route53.rb:9:in `<main>'
```

エラーメッセージも適切に出ていますが、色々余分なものが見えて利用者を混乱させる（バグかと思うなど）可能性があるので、しっかりエラー処理をするようにします。変更部部のみ抜粋。

```rb
begin
  resp = client.get_hosted_zone({
    id: id,
  })
rescue Aws::Route53::Errors::ServiceError
  puts '[ERROR] ' + $!.message
  exit 1
end
```

以下のようにすることで例外を取得して適切にエラーメッセージのみを表示します。

```bash
$ruby route53.rb
[ERROR] No hosted zone found with ID: 1111
```

いい感じになりました。

# OptionParserを使って引数を処理する

+ [RubyのOptionParserの底力を知る](http://d.hatena.ne.jp/zariganitosh/20140819/ruby_optparser_true_power)
+ [Rubyでコマンドライン引数を解析する](http://ksino.hatenablog.com/entry/2014/03/18/235145)

CLIツールだと引数を指定することが多いと思います。例えば本例だとidを引数で任意に指定できるようになればより汎用性が増します。

Rubyの標準ライブラリのOptionParserを使うことで面倒な引数の処理などを簡略化できるということで使ってみました。

```rb
require 'aws-sdk-core'
require 'pp'
require 'optparse'

REGION = 'ap-northeast-1'

def error_rescue(e)
  puts '[ERROR] ' + e.message
end

def cmdline
  args = {}
  OptionParser.new do |opt|
    opt.on('-i id', 'hosted zone id') {|v| args[:id] = v}

    begin
      opt.parse!(ARGV)
    rescue
      error_rescue($!)
      exit 1
    end
  end
  args
end

def get_hosted_zone(client, id)
  begin
    resp = client.get_hosted_zone({
      id: id,
    })
  rescue Aws::Route53::Errors::ServiceError
    error_rescue($!)
    exit 1
  end
  resp.hosted_zone
end

args = cmdline
client = Aws::Route53::Client.new(region: REGION)
hosted_zone = get_hosted_zone(client, args[:id])
pp hosted_zone
```

上記で`-i` オプションが記述されているにも関わらず、値が設定されていない場合にエラーと出来ます。また、`help` オプションの設定もされています。

```bash
$ruby route53.rb -h
Usage: route53 [options]
    -i id                            hosted zone id

$ruby route53.rb --help
Usage: route53 [options]
    -i id                            hosted zone id

$ruby route53.rb -i
[ERROR] missing argument: -i
```

そもそも`-i`すら指定していない場合やパラメーターの正当性（今回の場合数字か大文字の英語）などは出来ないようなので独自でメソッドを定義しました。

```rb
def error(msg)
  puts '[ERROR] ' + msg
end

def valid_params?(id)
  if id.nil?
    error "-i option is not specified.please input -i option to specify hosted zone id"
    return false
  end

  unless id =~ /^[0-9A-Z]+$/
    error "invalid -i option.-i option can be used to only alphanumeric character"
    return false
  end

  true
end

・・・

args = cmdline
exit 1 unless valid_params?(args[:id])

```

上記によって引数がない場合や設定値が正しくないには適切なエラーメッセージが出せます。

```bash
$ruby route53.rb
[ERROR] -i option is not specified.please input -i option to specify hosted zone id

$ruby route53.rb -i aaaaaa
[ERROR] invalid -i option.-i option can be used to only alphanumeric character
```

# loggerを使ってログを表示する

現在までputやを使ってログ情報を表示していましたが、loggerを使うことでログレベルの設定を変えたり、出力をファイルなどに簡単に変えることも出来るため、こちらを利用します。

最終的にはこんな感じになりました。

```rb
require 'aws-sdk-core'
require 'pp'
require 'optparse'
require 'logger'

REGION = 'ap-northeast-1'
$log = Logger.new(STDOUT)

def cmdline
  args = {}
  OptionParser.new do |opt|
    opt.on('-i id', 'hosted zone id') {|v| args[:id] = v}

    begin
      opt.parse!(ARGV)
    rescue => err
      $log.error err.message
      exit 1
    end
  end
  args
end

def valid_params?(id)
  if id.nil?
    $log.error "-i option is not specified.please input -i option to specify hosted zone id"
    return false
  end

  unless id =~ /^[0-9A-Z]+$/
    $log.error "invalid -i option.-i option can be used to only alphanumeric character"
    return false
  end

  true
end

def get_hosted_zone(client, id)
  begin
    resp = client.get_hosted_zone({
      id: id,
    })
  rescue Aws::Route53::Errors::ServiceError => err
    $log.error err.message
    exit 1
  end
  resp.hosted_zone
end

args = cmdline
exit 1 unless valid_params?(args[:id])

client = Aws::Route53::Client.new(region: REGION)
hosted_zone = get_hosted_zone(client, args[:id])
$log.debug "name = #{hosted_zone.name}"
```

実行すると以下のようにログが確認出来ます。

```bash
$ruby route53.rb -i 11111111111
D, [2015-08-28T14:35:20.410534 #5497] DEBUG -- : name = hogefuga.local.
```

ログをファイルに書き出す場合にもLoggerクラス生成時の引数を変えればよいだけなので楽です。