EC2 Run CommandがLinux対応したので簡易実行コマンドを作ってみた

# はじめに
本当は変態補完関数やtty操作系のネタをやろうと思ったんだけど、忙しさにかまけてエントリに纏められなかったのでもう少しライトなネタを探してたら丁度今日↓こんなネタが湧いてきたので、じゃコレを簡単に使うコマンド作ってみよう！

[Amazon Web Services ブログ: EC2 Run Commandアップデート – Linuxインスタンスで利用可能に](http://aws.typepad.com/aws_japan/2015/12/ec2-run-command-update-now-available-for-linux-instances.html)

てことでアドベントカレンダー18日目は、締切2時間前から突貫で書き上げたネタです。ホントは引数処理とかもう少しだけ足して普通に使えるコマンドにしたかったんですがそれは後日追記ということで…、今日は基本的な実行が出来るまでを作ってみました。

# Requirements
必要なもの

- サーバ側
    - awscli（最新）
    - `amazon-ssm-agent` がインストールされたEC2インスタンス
    - パブリックIP（NATとかでも良い、必要なのはssmのAPIを叩くのに使う外への通信経路）
    - SSM関連の適切な権限付きのロール（必要なポリシーとかの確認は[こちら](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ssm-iam.html)から）
- クライアント側
    - awscli（最新）
    - `ssm:*` の権限が適切に与えられた `~/.aws/credentials` 設定
    - `jq`

## まずサーバ側の準備をします
現時点ではまだ `us-east-1` リージョンでしか使えないので、そこにデモ用のEC2インスタンスを上げていきます。

- AMIは `AmazonLinux 2015.09` を選択
- インスタンスタイプはこれまた丁度リリースされたばかりの `t2.nano` を使ってみる
- 自動割り当てパブリックIPで `有効化` を選択
- 高度な詳細のユーザデータに以下をコピペで入力
    - awscliとか(?)が古い状態でssm-agentを動かしてもうまく動かないようなのでアップデートします
    - amazon-ssm-agentはインストールすると勝手に動きだします。なお /etc/init/amazon-ssm-agent.conf でrespawnされてるのでssm-agentが死んでも勝手に起動し直されます、最終手段としての安心設計ですね

```bash:user-data
#!/bin/bash
yum update -y
region=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/[a-z]$//')
yum install -y https://amazon-ssm-$region.s3.amazonaws.com/latest/linux_amd64/amazon-ssm-agent.rpm
```

- 外部から通信できる必要は無いので `default` セキュリティグループのみ付けておく（ホントは何もいらないけど1つは選択必須なので）
- 最後のキーペア選択はもちろん `キーペアなし` を選択（このデモでssh使う予定は無いので！）
- で、起動！


# クライアント側（本題）
では本題の `EC2 Run Command (Linux)` を簡単実行するスクリプトを書いてみます。
基本的な使い方ですが、ドキュメントやAPIをざっと読んでみて流れ的には以下のようにすれば良いっぽい事がわかりました。

- `aws ssm send-command` で対象インスタンスにシェルスクリプトを送りつける。
    - AWSの中の人が当該インスタンスの ssm-agent にコマンドを送り届けてくれて、
    - ssm-agentはコマンド実行が完了したらAWSに結果を返してくれるといったことをやってくれてるはず。
- `send-command`を実行するとCommandIdというのが取得できるのでそれを使って結果が出るのをポーリングして待ちます。
    - 結果取得には `aws ssm list-command-invocations` を使います。
    - このとき `--details` というオプションを付けておきましょう。コレが無いと実行時間と終了ステータスくらいしか帰ってこず肝心のコマンドの出力がもらえないので最初全部のAPI試してもどうやって結果を得れば良いのかちょっと悩みました(^^;
    - このコマンドの結果ステータスが Pending から Success（又はそれ以外）に変わるのを待ってやれば結果が取得できます。

で、↓完成品（75％）がこちらです。

```bash:ssm_exec.sh
#!/bin/bash
ssm_exec() {
  # ホントはリージョンやプロファイル指定とかの引数処理も付けたいところだが省略
  local instanceIds=$1; shift

  # コマンド送信
  local command_res=$(aws ssm send-command \
    --document-name AWS-RunShellScript \
    --instance-ids "$instanceIds" \
    --parameters "$(jq -nM --arg s "$*" '{"commands":[$s]}')"
  ) || return $?
  local command_id=$(jq -r .Command.CommandId <<<"$command_res")

  # 結果待ち
  while :; do
    local invocation=$(aws ssm list-command-invocations --details --command-id "$command_id") || return $?
    local invocation_status=$(jq -r .CommandInvocations[0].Status <<<$invocation) || return $?
    [[ $invocation_status == "Pending" ]] && { sleep 1; continue; }
    local command_code=$(jq -r .CommandInvocations[0].CommandPlugins[0].ResponseCode <<<$invocation)
    local command_output=$(jq -r .CommandInvocations[0].CommandPlugins[0].Output <<<$invocation)
    # 標準出力と標準エラーに分離する（別々には取れない模様…、そして謎のセパレータで区切られている雑感(^^;）
    local command_stdout=${command_output%%$'\n----------ERROR-------\n'*}
    local command_stderr=${command_output#*$'\n----------ERROR-------\n'}
    # 標準出力を出力
    if [[ -n $command_stdout ]]; then
      echo "$command_stdout"
    fi
    # 標準エラーを出力
    if [[ $command_output == *$'\n----------ERROR-------\n'* ]]; then
      echo -en '\x1b[33m' >&2
      echo "$command_stderr" >&2
      echo -en '\x1b[0m' >&2
    fi
    # リモートコマンドのレスポンスコードを返して終了
    return $command_code
  done
}

# 単品で動くbash関数を作ってからコマンド引数を全部渡してるのはただの趣味
# コーディング規約とまではいかずともこうしておくと、コピペだけで試しやすいしね
ssm_exec "$@"
```

## 実行してみた
いい感じに使えてますね。

[![asciicast](https://asciinema.org/a/eley3h1fi3zcb4vg5h0zon7ds.png)](https://asciinema.org/a/eley3h1fi3zcb4vg5h0zon7ds)

- 冒頭 MFA code の入力をしてるのは、普段僕が権限が全くないIAMユーザのアクセスキーを使って認証してて、必要に応じて必要なロールにスイッチして暮らしてる為です。横道ですがこの方法だと強権にMFAが書けられるのが良いです。アクセスキーにはMFAがかけられないから万が一漏れた時に怖いんですよね。
- 今回はスルーしたけど
    - 複数インスタンスに同時にコマンド流しこんだりもできます。
    - あとスクリプトの入力や出力をS3にすることもできます。S3を経由する形なら大きな出力がtruncateされることなく全部見られるので出来れば対応しておきたいところです。
- あと全体的にキー入力がもたもたしててデモのテンポが悪いのはご愛嬌ということで(^^;
    - `asciicinema`便利なんだけど倍速再生とかないのかな？

# そういえば Shell Script カレンダーだった
一応使ってる Bash のTipsを軽く書いておきます。

- 関数書くときは local 付けるのは基本。
- `--parameters "$(jq -nM --arg s "$*" '{"commands":[$s]}')"` で使ってる、jqの`--arg`ってオプションは、生の文字列をJSONの世界に持ってく時とかに凄く便利です。
- `<<<` - ヒアストリングっていう機能なんだけど、文字列をワンライナーで標準入力にぶち込めるので便利です。`echo "$x" | command` だと変数に -e とかオプション引数みたいなのが入ってないか気にするのが面倒だから避けたい。
- `[[ $command_output == *$'\n----------ERROR-------\n'* ]]`、 bash 使ってる時は `[[ ]]` これ使って条件式を書くの重要です。似たようなので `[ ]` ってのがあるけど実は全然違うどれくらい違うかは長くなるので割愛するが。例えばここで使ってる `[[ $a == foo* ]]` みたいなやつこれはファイル名のgrobと同じことを文字列でやってるやつで、エスケープとか気にしないで文字列の部分一致チェックができる的なモノで、余計なことしない分、単体文字列チェックする際は grep より便利なことも多い。なお同じことを `[ ]` でやるとカレントディレクトリに foobar みたいなファイルがあったら $a == foobar をチェックすとか言うマヌケなことになったりする。
- 改行とかタブとか書きたいときは `$'\t\n'` とかで「タブ文字と改行文字の2文字」という文字列が作れたりするので便利です。
- `echo hoge >&2` 標準入力を標準エラーに繋げるやつで、これは要は hoge を標準エラーに出力している。
- `echo -en '\x1b[33m'; echo -n "黄色"; echo -e '\x1b[0m'` エスケープ文字使うとコンソールに色付できたりする。この例だと最初のやつが黄色くするので、最後のは色とか色々リセットする、それで挟まれた間に出力された文字は聞いとくなる。
- あとシェルスクリプト書くときって、ベタに処理を書くよりも、一度関数にしてそれを呼ぶコマンドを作るほうがコピペビリティ高くて好きです。