Ansible・serverspec連携でインベントリと変数を一括管理する実装例（複数サーバ対応）

# はじめに

Ansibleで設定したLinuxサーバ群に対して、serverspecでテストする際に抱えていた以下のような問題を解決するために、Ansibleとserverspecを魔改造（？）してみました。

* Ansibleとserverspecそれぞれで変数ファイルを用意するのが面倒
* Ansibleとserverspecそれぞれで対象ノードを記述したインベントリファイルを用意するのが面倒
* `serverspec-init` で提供される初期設定では、テストコードをテスト対象サーバ名のついたディレクトリ毎に用意する必要があり面倒

Ansibleとserverspecを連携させて活用する際の入門編・初級編として、参考になさってください。

# ゴール

以下が実現できるように実装します。

* Ansible のインベントリを serverspecでも使い回す
* Ansible Playbook実行時の変数を使って、serverspecでテストする
* Ansibleの変数は指定方法によって優先度が決められているので、最終的にノードに対してPlaybookが実行された際に使われた変数で、serverspecでテストする
* 複数ノードに対して、Ansibleしてserverspecする
* serverspecはロールごとにテストコードを分ける


# 実装例

## 環境
### Ansible Control Node
* CentOS 7.6
* ansible 2.9.0
* serverspec 2.41.5
* ruby 2.6.3p62
* Python 2.7.5

### Ansible Managed Node
* CentOS 7.6 × 2 Node

## ディレクトリ構成

```shell
$ tree -aF /autotools
/autotools
|-- .ssh/
|   `-- aws_key.pem    # ManagedNodeのSSH秘密鍵
|-- ansible/
|   |-- ansible.cfg
|   |-- group_vars/    # グループ用変数ディレクトリ
|   |-- host_vars/     # ホスト用変数ディレクトリ
|   |-- inventory/     # Ansible向けインベントリ配置ディレクトリ
|   `-- centos.yml       # Playbook
`-- serverspec/
    |-- .rspec
    |-- Rakefile
    |-- spec/
    |   |-- base/      # baseロール向けのテストコート配置ディレクトリ
    |   |   `-- sample_spec.rb # テストコード
    |   `-- spec_helper.rb
    `-- spec_hosts/    # serverspec向け変数配置ディレクトリ
```

## Ansible

まず今回のサーバ群をまとめて管理する目的で、`project_name` を英文字列で決めましょう。
ここでは例として `anken` という `project_name` にします。

### ansible.cfg
ここでは、環境変数 `ANSIBLE_CONFIG` で使用する `ansible.cfg` を指定します。
私はコード開発で同じホスト名を使いまわしているので、ssh時の引数をここに記載しています。

```shell
$ export ANSIBLE_CONFIG=/autotools/ansible/ansible.cfg
```

```ini:/autotools/ansible/ansible.cfg
[defaults]

[ssh_connection]
ssh_args = -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no

[privilege_escalation]
become = true
```

### SSH秘密鍵
Ansible Managed NodeにSSHする際に使うSSH秘密鍵を `/autotools/.ssh` に配置します。
鍵のPathはインベントリファイルに記載します。

### インベントリファイル
`/autotools/ansible/inventory/` 配下にインベントリファイル `anken.ini` を配置してください。

基本的にAnsibleのルールに従って記述すればOKですが、serverspecと連携するための仕組みで使うので、以下の3つは必須で設定してください。

* ansible対象ノードはすべて プロジェクト名のグループに所属させてください
* `[all:vars]` で `project_name` を指定してください
* ansibleが 対象ノードにssh loginする際に使うユーザID、パスワード or SSH鍵を、それぞれ `ansible_user`、`ansible_password`、`ansible_ssh_private_key_file`で設定してください。パスワードとSSH鍵はどちらか1つを設定してください。（ここに書いたパスワードとSSH鍵は、後述する変数ファイルを通じて、serverspecでも利用されます）


```ini:/autotools/ansible/inventory/anken.ini
[anken]
prod_foobar1 ansible_host=xx.xx.xx.xx
dev_foobar1 ansible_host=yy.yy.yy.yy

[anken:vars]
ansible_user=centos
ansible_ssh_private_key_file=~/.ssh/aws_key.pem

[all:vars]
project_name=anken
```

Ansibleの実行は、`ansible_host`のIPもしくは名前に対して実行されます。

`inventory_hostname` （例で `prod_foobar1` `dev_foobar2` を指定している箇所）は、ノードの実際のホスト名と一致する必要はありません。

### Playbook

この実装例で使うPlaybook例は以下の通りです。
`name: Configure for serverspec at localhost` で、serverspecが利用する変数ファイルを出力しています。

```yml:centos.yml
---
- name: Playbook for centos7 managed node
  hosts: all
  gather_facts: true

  tasks:
    - name: Create group
      group:
        name: "{{ item.name }}"
        gid: "{{ item.gid }}"
      loop: "{{ group }}"
      tags: group

    - name: Create User
      user:
        name: "{{ item.name }}"
        uid: "{{ item.uid }}"
        group: "{{ item.group }}"
        groups: "{{ item.groups }}"
        home: "{{ item.home }}"
        shell: "{{ item.shell }}"
      loop: "{{ user }}"
      tags: user

    - name: System service
      systemd:
        name: "{{ item.name }}"
        enabled: "{{ item.enabled }}"
        state: "{{ item.state }}"
      loop: "{{ service }}"
      tags: service

- name: Configure for serverspec at localhost
  hosts: localhost
  connection: local
  gather_facts: false
  tasks:

    - name: Dump hostvars for serverspec
      copy:
        content: "{{ hostvars | to_nice_yaml }}"
        dest: "../serverspec/spec_hosts/{{ project_name }}.yml"
      tags: serverspec
```

### 変数ファイル配置

projectに共通な変数は `/autotools/ansible/group_vars/#{project_name}.yml` に配置します。特定のinventory_hostnameにだけ別の変数を指定したい場合は、 `/autotools/ansible/host_vars/#{inventory_name}.yml`に配置します。

変数ファイル内で、serverspecでテストする際に使用するroleを、`serverspec_role` で指定してください。

```yml:/autotools/ansible/group_vars/anken.yml
serverspec_role:
  - base
group:
  - name: unyo
    gid: 1101
  - name: infra
    gid: 1102
  - name: app
    gid: 1103
user:
  - name: user1
    uid: 2001
    group: customer
    groups: [ unyo, infra]
    home: /home/user1
    shell: /bin/bash
  - name: user2
    uid: 2002
    group: customer
    groups: [ app ]
    home: /home/user2
    shell: /bin/bash
  - name: user3
    uid: 2003
    group: customer
    groups: [ app, infra ]
    home: /home/user3
    shell: /bin/bash
service:
  - name: chronyd.service
    enabled: false
    state: stopped
  - name: rsyncd.service
    enabled: true
    state: started
```

ここでは、`prod_foobar` ノードに限定して、一部の変数を上書きしてみます。

```yml:/autotools/ansible/group_vars/prod_foobar.yml
group:
  - name: unyo
    gid: 2101
  - name: infra
    gid: 2102
  - name: app
    gid: 2103
```

### ディレクトリ・ファイル構成

必要なファイルを配置したあと、こんな感じになってるはずです。

```
$ tree /autotools/ansible -aF
/autotools/ansible
|-- ansible.cfg
|-- centos.yml
|-- group_vars/
|   `-- anken.yml
|-- host_vars/
|   `-- prod_foobar1
`-- inventory/
    `-- anken.ini
```

### 実行

以下のように、インベントリファイルを指定して centos.yml Playbookを実行してください。

```shell
$ cd /autotools/ansible
$ ansible -i ./inventory/anken centos.yml
```

## serverspec

### ディレクトリ・ファイル構成
Ansible実行後、serverspec側のディレクトリ・ファイル構成はこんな感じになってるはずです。

```shell
# tree /autotools/serverspec -aF
/autotools/serverspec
|-- .rspec
|-- Rakefile
|-- spec/
|   |-- base/
|   |   `-- sample_spec.rb
|   `-- spec_helper.rb
`-- spec_hosts/
    `-- anken.yml        # Ansibleによって生成された変数ファイル
```

### 実行コマンド

順番が前後しますが、serverspecの実行コマンドは以下になります。
serverspecで使用する（Ansibleによって生成された）変数ファイル名を、rakeコマンドに引数として渡してあげるイメージです。

```shell
$ rake spec anken -T
rake spec               # Run spec to all hosts
rake spec:dev_foobar1   # Run spec to dev_foobar1
rake spec:prod_foobar1  # Run spec to prod_foobar1
$ rake spec anken
```


### Rakefile

`serverspec-init` で作られる標準の `Rakefile` からいくつか変更しています。

* Ansibleが生成した 変数ファイルを読み込むための処理を追加
* serverspec_role と同じ名前のディレクトリ配下の *_spec.rb ファイルを読み込み
* rakeコマンドの引数が rakeタスクと誤認されてエラーになっちゃうので、引数と同じ名前の空タスク作成

```ruby:/autotools/serverspec/Rakefile
require 'rake'
require 'rspec/core/rake_task'
require 'yaml'

# 変数ファイルを読み込み
project_name = ARGV[1]
hosts = YAML.load_file("./spec_hosts/#{project_name}.yml")

desc "Run spec to all hosts"
task :spec => 'spec:all'

namespace :spec do
  task :all => hosts.keys.map {|key| 'spec:' + key }

  hosts.keys.each do |key|
    desc "Run spec to #{key}"
    RSpec::Core::RakeTask.new(key.to_sym) do |t|
      ENV['INVENTORY_HOST'] = key
      ENV['PROJECT_NAME'] = project_name
      # serverspec_role と同じ名前のディレクトリ配下の *_spec.rb ファイルを読み込み
      t.pattern = 'spec/{' + hosts[key]['serverspec_role'].join(',') + '}/*_spec.rb'
      t.fail_on_error = false
    end
  end
end

# rakeコマンドの引数を空タスクとして偽造
ARGV.slice(1,ARGV.size).each{|v| task v.to_sym do; end}
```

以下参考にさせていただきました。ありがとうございました。

参考：Rakeタスクで普通の引数っぽい処理を書く
https://qiita.com/nao58/items/aa50514d97f05eb8d128

参考：公式 How to use host specific properties
https://serverspec.org/advanced_tips.html

### spec_helper.rb

こちらも初期状態の `spec_helper.rb` からいくつか機能を変更しています。

* Ansibleが生成した 変数ファイルを読み込むための処理を追加
* 読み込んだ変数ファイルから、Ansibleで使った host、user、password or key を抽出

```ruby:/autotools/serverspec/spec/spec_helper.rb
require 'serverspec'
require 'pathname'
require 'net/ssh'
require 'yaml'

# 変数ymlファイル読み込み
key = ENV['INVENTORY_HOST']
project_name = ENV['PROJECT_NAME']
properties = YAML.load_file("./spec_hosts/#{project_name}.yml")
set_property properties["#{key}"]

set :backend, :ssh
set :path, '/sbin:/usr/sbin:$PATH'

# ssh実行部
RSpec.configure do |c|
  c.before :all do
    # 読み込んだ変数ファイルから、Ansibleで使った host、user、password or key を抽出
    set :host, property['ansible_host']
    options = Net::SSH::Config.for(c.host)
    options[:user] = property['ansible_user']
    if property['ansible_password']
      options[:password] = property['ansible_password']
    else
      options[:keys] = [ property['ansible_ssh_private_key_file'] ]
    end
    options[:user_known_hosts_file] = '/dev/null'
    set :ssh_options, options
  end
end
```

`set :backend, :ssh` としているので、この `spec_helper.rb` は WinRM には対応していません。とはいえ、Ruby でなんでも書けるので、きっと Windows対応も難しくはないでしょう。

### テストコード

こちらはサンプルです。
`spec_helper.rb` で書いているとおり、`property['xxx']` で変数ファイルから変数を取り出して再利用可能です。

```rb:/autotools/serverspec/spec/base/
# frozen_string_literal: true

require 'spec_helper'

puts "\nRun serverspec to #{property['inventory_hostname']}"

property['group'].each do |attr|
  describe group(attr['name']) do
    it { should exist }
    it { should have_gid attr['gid'] }
  end
end

property['user'].each do |attr|
  describe user(attr['name']) do
    it { should exist }
    it { should have_uid attr['uid'] }
    it { should belong_to_group attr['group'] }
  end
end

property['service'].each do |attr|
  describe service(attr['name']) do
    attr['enabled']            ? it { should be_enabled } : it { should_not be_enabled }
    attr['state'] == 'started' ? it { should be_running } : it { should_not be_running }
  end
end
```

### 実行

繰り返しになりますが、以下のように、rake spec コマンドの引数に Ansibleが生成した変数ファイル名を引数としてつけて実行してあげてください。一台ごとのテスト実行も可能です。

```shell
$ rake spec anken
$
$ rake spec anken -T    # タスク一覧を表示するコマンド
rake spec               # Run spec to all hosts
rake spec:dev_foobar1   # Run spec to dev_foobar1
rake spec:prod_foobar1  # Run spec to prod_foobar1
$
$ rake spec:dev_foobar1 anken
```


# まとめ

Ansible と serverspec で二重管理になりがちな 変数ファイルとイベントリファイル を一本化することができました。また、serverspec公式を記載の方法を参考に、ロール単位でテストコードを管理・実行することができました。

serverspec は かなり Ruby色なツールなので、普段Rubyに触れていない方にはとっつきにくいですが、慣れてくると、いろんな処理が書きやすくて良いですね。

## サンプルコード
以下で公開しています。
https://github.com/kentarok/autotools