Serverspecの実行結果を手っ取り早くCSVにする

# TL;DR

- Serverspec（RSpec？）の出力形式には一目でテストケースと結果の一覧がわかるような形式が無いよ
- custom formatter作り込みたくないよ
- jsonからcsvに変換すれば簡単に作れるよ
- afterフックを使うと変換を自動化できるよ

# 概要

※RSpecを使ったことがないのでServerspecの話として書いています。

Serverspecの出力形式はRSpecのformatオプションで以下の形式を指定できる。

- progress（デフォルト）
- documentation
- html
- json
- custom formatter（自作の形式）

簡易的な表示でよい場合はprogress、可読性を高くしたい場合はdocumentation、プログラマブルに扱いたい場合はjson、ブラウザで見たかったらhtml、どれも気に入らない場合はcustom formatterを自作みたいな感じで使い分けられると思う。

ただ、documentationもjsonもhtmlも一つの結果を複数行にわたって表示する形式のため、ざっとテストケースと結果の一覧を見たいときには結構見辛い。

なので1行に1テストケースとなるようなCSV形式で出力したいが、そのためにわざわざcustom formatterを作り込みたくはないので、jqを使ってjsonからCSVを出力する。

# JSON to CSV

以下のspecファイルを実行するとする。

```rb:./spec/hoge/main_spec.rb
require 'spec_helper'

describe('sshdサービス') do
  describe('が有効であること') do
    describe service('sshd') do
      it { should be_enabled }
    end
  end
  describe('が起動していること') do
    describe service('sshd') do
      it { should be_running }
    end
  end
end
```

あらかじめ.rspecやRakefileにjson形式で出力するオプションを記載しておくか、rspec実行時に指定する。  
複数台に同時実行する場合は出力ファイル名に変数を使う必要があるが、.rspecでは変数で指定できない？（環境変数で渡したりもできなかった）ので、Rakefileで指定する方が汎用性あると思う。

なお、formatを複数指定すると1回の実行で複数形式の出力が行われるので、コンソールへの表示（-oオプションを指定しない）はprogressで、ログとして保存するのはjsonとdocumentationみたいなこともできる。

```rb:.rspec
# .rspecで指定する場合の例（複数台の場合には対応不可？）
--format json -o result.json
```

```rb:Rakefile
# Rakefileで指定する場合の例（複数台の場合にも対応可能）

## 省略 ##
targets.each do |target|
  desc "Run serverspec to #{target}"
  RSpec::Core::RakeTask.new(target.to_sym) do |t|
    ## 省略 ##
    t.rspec_opts = "--format json -o audit/json/#{target}.json"
  end
end
```

Serverspecの実行が終わると以下のような形式で出力される。

```json:result.json
{"version":"3.8.0","examples":[{"id":"./spec/hoge/main_spec.rb[1:1:1:1]","description":"should be enabled","full_description":"sshdサービス が有効であること Service \"sshd\" should be enabled","status":"passed","file_path":"./spec/hoge/main_spec.rb","line_number":6,"run_time":1.606149747,"pending_message":null},{"id":"./spec/hoge/main_spec.rb[1:2:1:1]","description":"should be running","full_description":"sshdサービス が起動していること Service \"sshd\" should be running","status":"passed","file_path":"./spec/hoge/main_spec.rb","line_number":11,"run_time":0.068922039,"pending_message":null}],"summary":{"duration":1.688882982,"example_count":2,"failure_count":0,"pending_count":0,"errors_outside_of_examples_count":0},"summary_line":"2 examples, 0 failures"}
```

これだと見辛いので整形するとこうなる。

```bash
cat result.json "." | jq
```

```json
{
  "version": "3.8.0",
  "examples": [
    {
      "id": "./spec/hoge/main_spec.rb[1:1:1:1]",
      "description": "should be enabled",
      "full_description": "sshdサービス が有効であること Service \"sshd\" should be enabled",
      "status": "passed",
      "file_path": "./spec/hoge/main_spec.rb",
      "line_number": 6,
      "run_time": 1.606149747,
      "pending_message": null
    },
    {
      "id": "./spec/hoge/main_spec.rb[1:2:1:1]",
      "description": "should be running",
      "full_description": "sshdサービス が起動していること Service \"sshd\" should be running",
      "status": "passed",
      "file_path": "./spec/hoge/main_spec.rb",
      "line_number": 11,
      "run_time": 0.068922039,
      "pending_message": null
    }
  ],
  "summary": {
    "duration": 1.688882982,
    "example_count": 2,
    "failure_count": 0,
    "pending_count": 0,
    "errors_outside_of_examples_count": 0
  },
  "summary_line": "2 examples, 0 failures"
}
```

examplesの中に各結果が格納されていることがわかるので、これをjqを使ってCSVに変換する。

id, status, full_descriptionの3つだけ抜き出したい場合のコマンドは以下

```bash
cat result.json | jq '.examples' | jq -r '.[] | [.id, .status, .full_description] | @csv'
```

これを実行すると以下のような出力が得られる。

```bash
"./spec/hoge/main_spec.rb[1:1:1:1]","passed","sshdサービス が有効であること Service ""sshd"" should be enabled"
"./spec/hoge/main_spec.rb[1:2:1:1]","passed","sshdサービス が起動していること Service ""sshd"" should be running"
```

1行でspecファイルのパス、番号、結果、テスト内容が表示され、一目でわかりやすい形式となっている。
後はリダイレクトでファイルに吐き出せばcustom formatterを使用せずに簡単にCSV形式で出力ができることになる。
ヘッダ部分は普通にechoで出力する。

```bash
echo "id,status,full_description" > result.csv
cat result.json | jq '.examples' | jq -r '.[] | [.id, .status, .full_description] | @csv' >> result.csv
```

```bash:result.csv
id,status,full_description
"./spec/hoge/main_spec.rb[1:1:1:1]","passed","sshdサービス が有効であること Service ""sshd"" should be enabled"
"./spec/hoge/main_spec.rb[1:2:1:1]","passed","sshdサービス が起動していること Service ""sshd"" should be running"
```

完成！

# Serverspec実行完了後にCSV自動生成

Serverspec実行後に毎回上記のコマンドを実行するのは面倒なので、自動的にCSVに変換して出力してくれるようにする。

ここからは複数台同時実行する場合を想定して、以下のディレクトリ構造でauditディレクトリの中にjsonとcsvの各種ログが保存されるようにする。

```bash
.
├── audit
│   ├── csv
│   │   ├── target1.csv
│   │   └── target2.csv
│   └── json
│       ├── target1.json
│       └── target2.json
├── json2csv.sh
├── Rakefile
└── spec
　   ├── hoge
　   │   └── main_spec.rb
　   └── spec_helper.rb
```

まずはjsonからcsvへ変換して出力するコマンドをシェルスクリプト化しておく。

``sleep``が重要だが、理由は後述

```bash:json2csv.sh
#!/bin/sh

TARGET=$1

# jsonファイルに書き込みが終了するのを待つ
sleep 2

if [ ! -e ./audit/csv ]
then
  mkdir ./audit/csv
fi

# JSONをCSVに変換して出力
echo "id,status,full_description" > audit/csv/$TARGET.csv
cat audit/json/$TARGET.json | jq '.examples' | jq -r '.[] | [.id, .status, .full_description] | @csv' >> audit/csv/$TARGET.csv
```

各サーバへのServerspec実行後に上記のシェルスクリプトが実行されるようにしておけばよいが、
RSpecにはbefore/afterフック（正式名称なのか知らない）というものがあり、これを利用することで特に面倒なことをしなくても実現できる。

具体的には``spec_helper.rb``に以下のような記述を行う。

```rb:spec_helper.rb
## 省略 ##
RSpec.configure do |c|
  target  = ENV['TARGET_HOST']
  ## 省略 ##
  c.after(:suite) do
    # JSON形式のログからCSV形式のログを作成
    # RSpecのタスクが実行終了するまでjsonファイルへの書き込みが行われないようなので、バックグラウンドで遅延実行させる
    system("sh ./json2csv.sh #{target} &")
  end
end
```

jsonファイルに中身が書き込まれる処理が呼ばれるのはRSpecのtask終了後のようなので、バックグラウンド実行（末尾の&）を行うことでシェルスクリプトの実行が終わる前にjsonファイルへの書き込みを行わせる。
更に、シェルスクリプト内のコマンドの実行が早すぎても上手くいかないので、``sleep``で若干処理を遅らせている。（sleep時間は適当）

この状態でServerspecを実行すると、各サーバへの実行完了後に自動的にjsonとcsvのファイルが出力される。

# 余談

変換後のCSVにインデックスが無いのが気に入らなかったが、nlコマンドを使うと簡単に追加できた。

```bash
# JSONをCSVに変換して出力
echo "index,id,status,full_description" > audit/csv/$TARGET.csv
cat audit/json/$TARGET.json | jq '.examples' | jq -r '.[] | [.id, .status, .full_description] | @csv' | nl -w 1 -s ", " >> audit/csv/$TARGET.csv
```

また、Rakefileでrspec_optsを複数指定したいときは以下のように書ける。

```rb:Rakefile
## 省略 ##
RSpec::Core::RakeTask.new(target.to_sym) do |t|
  ## 省略 ##
  t.rspec_opts = "\
  --color\
  --require ./formatters/Serverspec_audit_formatter.rb\
  --format ServerspecAuditFormatter\
  --format ServerspecAuditFormatter -o audit/display/#{target}.log\
  --format json -o audit/json/#{target}.json\
  "
end
```