Goss による高速テストの実装検討

## はじめに

普段、[Serverspec](https://serverspec.org/) を利用してサーバー構築後のテストを行っているのですが、対象サーバー台数やテスト項目が肥大化して、テスト実行時間が１時間くらい掛かるようになり、代替手段を探したところ、 [Goss](https://github.com/aelsabbahy/goss) という Go製のテストフレームワークを見つけました。

そこで、 Goss のインストール方法、使いかたについて検討することにしました。

## インストール方法

[Goss公式サイト](https://github.com/aelsabbahy/goss)にあるように、 `curl | sh` で簡単にインストールできます。

```
curl -fsSL https://goss.rocks/install | sh
```

環境によってはインターネットからダウンロードができないシーンもあるので、バイナリファイルをあらかじめダウンロードしておいて、運用端末から配布してもよさそうです。

## 使いかた

ヘルプを見てみます。

```
$ goss --help
NAME:
   goss - Quick and Easy server validation

USAGE:
   goss [global options] command [command options] [arguments...]

VERSION:
   v0.3.16

COMMANDS:
     validate, v  Validate system
     serve, s     Serve a health endpoint
     render, r    render gossfile after imports
     autoadd, aa  automatically add all matching resource to the test suite
     add, a       add a resource to the test suite
     help, h      Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --gossfile value, -g value  Goss file to read from / write to (default: "./goss.yaml") [$GOSS_FILE]
   --vars value                json/yaml file containing variables for template [$GOSS_VARS]
   --vars-inline value         json/yaml string containing variables for template (overwrites vars) [$GOSS_VARS_INLINE]
   --package value             Package type to use [apk, dpkg, pacman, rpm]
   --help, -h                  show help
   --version, -v               print the version
```

### テストシナリオの作りかた

Serverspec にはない特徴的な機能として、 `autoadd`, `add` があります。これらは、今ある状態を正として自動的にテストシナリオを生成するものです。

#### autoadd

`file`, `group`, `package`, `port`, `process`, `service`, `user` を探索し、マッチするものをもとにテストシナリオを生成します。

例えば、 `sshd` を指定します。

```
$ goss autoadd sshd
```

`goss.yaml` には以下のようなテストシナリオが生成されます。（すでにファイルが存在する場合は追記されます）

```yaml:goss.ayml
port:
  tcp:22:
    listening: true
    ip:
    - 0.0.0.0
  tcp6:22:
    listening: true
    ip:
    - '::'
service:
  sshd:
    enabled: true
    running: true
user:
  sshd:
    exists: true
    uid: 74
    gid: 74
    groups:
    - sshd
    home: /var/empty/sshd
    shell: /sbin/nologin
group:
  sshd:
    exists: true
    gid: 74
process:
  sshd:
    running: true
```

`-g` オプションで出力するファイルを指定することができます。

#### add

`autoadd` では対応できないテスト（`addr`, `command`, `dns`, `http`, `interface`, `kernel-param`, `mount`）は `add` を使って生成します。

```
$ goss add command "cat /etc/passwd | wc -l"
Adding Command to './goss.yaml':

cat /etc/passwd | wc -l:
  exit-status: 0
  stdout:
  - "32"
  stderr: []
  timeout: 10000
```

#### 手動編集

YAMLファイルを手動で編集します。使用可能なリソースタイプは以下の通りです。詳しくは [goss manual](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md) を参照してください。

| リソースタイプ | 説明 |
|---------------|-------------|
| addr | リモートの `address:port` に接続できるかを評価する |
| command | コマンドを実行し、 exit status や出力を評価する |
| dns | アドレスの名前解決ができるかを評価する |
| file | ファイル、ディレクトリ、シンボリックリンクの状態を評価する |
| gossfile | gossfile をインポートする |
| group | グループの状態を評価する |
| http | HTTPレスポンスステータスコードやコンテンツを評価する | 
| interface | ネットワークインターフェースの値を評価する |
| kernel-param | カーネルパラメータ(sysctl) の値を評価する |
| mount | マウントポイントの attribute を評価する |
| matching | matcher に対して特定のコンテンツを評価する |
| package | パッケージの状態を評価する |
| port | ローカルポートの状態を評価する |
| process | プロセスが起動しているかを評価する |
| service | サービスの状態を評価する |
| user | ユーザーの状態を評価する |

注意点として、同じファイルに同じ内容のテスト項目を書くことはできません。（片方が無視されます）

```yaml:誤った書きかた
file:
  /etc/ssh/sshd_config:
    exists: true

file:
  /etc/httpd/conf/httpd.conf:
    exists: true
```

```yaml:正しい書きかた
file:
  /etc/ssh/sshd_config:
    exists: true
  /etc/httpd/conf/httpd.conf:
    exists: true
```

同じファイルでなければよいので、別々のファイルに書いておいて、 `gossfile` でインポートすると気にしなくてもよさそうです。

### 応用的な書きかた

#### 変数とループ

変数ファイルを用意しておき、ループで呼び出すことができます。

| 変数| 説明 |
|-----------|-------------|
| `{{.Env}}` | 環境変数 |
| `{{.Vars}}` | `--vars` で指定したファイルで定義した変数 |

##### 例1. Vars

```yaml:vars.yaml
users:
  - user1
  - user2
```

```yaml:goss.yaml
user:
{{range .Vars.users}}
  {{.}}:
    exists: true
    groups:
    - {{.}}
    home: /home/{{.}}
    shell: /bin/bash
{{end}}
```

##### 例2. Vars, Env

```yaml:vars.yaml
centos:
  packages:
    kernel:
      - "4.9.11-centos"
      - "4.9.11-centos2"
debian:
  packages:
    kernel:
      - "4.9.11-debian"
      - "4.9.11-debian2"
```

```yaml:goss.yaml
package:
# Looping over a variables defined in a vars.yaml using $OS environment variable as a lookup key
{{range $name, $vers := index .Vars .Env.OS "packages"}}
  {{$name}}:
    installed: true
    versions:
    {{range $vers}}
      - {{.}}
    {{end}}
{{end}}
```

##### 例3. if

```yaml:goss.yaml
package:
{{if eq .Env.OS "centos"}}
  # This test is only when $OS environment variable is set to "centos"
  libselinux:
    installed: true
{{end}}
```

#### パターン

`file`, `command`, `output` で使えます。

| パターン | 説明 |
|---------|-------------|
| `"string"` | `string` を含むか |
| `"!string"` | `string` を含まないか |
| `"\\!string"` | `!string` を含むか |
| `"/regex/"` | 正規表現にマッチするか |
| `"!/regex/"` | 正規表現にマッチしないか |

```yaml:sshd_config.yaml
file:
  /etc/ssh/sshd_config:
    exists: true
    contains:
    - "/^PermitRootLogin no/"
    - "/^UseDNS no/"
```

実行例は以下の通りです：
```
$ goss --gossfile sshd_config.yaml validate --format tap
1..2
ok 1 - File: /etc/ssh/sshd_config: exists: matches expectation: [true]
ok 2 - File: /etc/ssh/sshd_config: contains: all expectations found: [/^PermitRootLogin no/, /^UseDNS no/]
```

マッチしないと以下のような結果になります。
```
$ goss --gossfile sshd_config.yaml validate --format tap
1..2
ok 1 - File: /etc/ssh/sshd_config: exists: matches expectation: [true]
not ok 2 - File: /etc/ssh/sshd_config: contains: patterns not found: [/^UseDNS yes/]
```

### command

`exit-status`, `stdout`, `stderr` で評価します。
`exit-status` は必須のようです。定義しないとエラーになりました。

```yaml:passwd.yaml
command:
  number_of_users:
    exec: "cat /etc/passwd | wc -l"
    exit-status: 0
    stdout: 
      - 32
```
```
$ goss --gossfile passwd.yaml validate --format tap
1..2
ok 1 - Command: number_of_users: exit-status: matches expectation: [0]
ok 2 - Command: number_of_users: stdout: all expectations found: [32]
```

### gossfile

複数のファイルをインクルードできます。

```yaml:goss.yaml
gossfile:
  include/common/*.yaml: {}
  include/target/*.yaml: {}
```

## テストシナリオ作成時の Tips

`render` は すべての gossfile をインポートしたあとの gossfile を生成する機能ですが、バリデーションチェックとしても使えます。
エラー発生時の実行例は以下の通りです：
```
$ goss --gossfile ss.yaml --vars vars.yaml render
could not unmarshal "command:\n  \n  - version:\n    exec: \"ss -tuna | grep ':22 ' | grep LISTEN\"\n    exit-status: 0\n  \n  - version:\n    exec: \"ss -tuna | grep ':80 ' | grep LISTEN\"\n    exit-status: 0\n  \n  - version:\n    exec: \"ss -tuna | grep ':443 ' | grep LISTEN\"\n    exit-status: 0\n  \n" as YAML data: yaml: unmarshal errors:
  line 3: cannot unmarshal !!seq into map[string]map[string]interface {}
```

## 実践投入に向けて

Ansible で以下の手順で実行することにします。

1. goss およびテストコードをターゲットノードに送る
2. テストを実行する
3. 結果をファイルに出力する

※必要に応じてターゲットノードの資材を削除してもよいでしょう

### PoC

以下のようなディレクトリ・ファイル構成とします。

```
.
├── files
│   └── goss ............ ターゲットノードに送るテスト環境一式
│       ├── bin
│       │   └── goss .... goss本体
│       └── goss.yaml ... テストシナリオ
├── goss_local.yml ...... Ansible Playbook
├── library
│   └── goss.py ......... goss 呼び出し用ライブラリ
└── log ................. テスト実行結果格納用
```

`goss.py` は [ansible-goss](https://github.com/indusbox/goss-ansible) を利用させていただきました。

Ansible Playbook は以下の通りです。 PoC なので、ローカル実行かつ１ファイルなのはご容赦ください。また、実行環境が少々古いので、モジュール名がFQCNではありません。適宜 `ansible.builtin.copy` などと読み替えてください。

```yaml:goss_local.yml
---
- hosts: localhost
  connection: local
  become: yes
  gather_facts: no
  tasks:
  - name: copy goss files
    copy:
      src: goss
      dest: /tmp
  
  - name: delegate the right to execute
    file:
      path: /tmp/goss/bin/goss
      owner: root
      group: root
      mode: 0755
  
  - name: execute goss
    goss:
      path: /tmp/goss/goss.yaml
      format: tap
      output_file: "/tmp/goss/goss_{{ inventory_hostname }}.log"
      goss_path: /tmp/goss/bin/goss
    ignore_errors: yes
  
  - name: fetch log
    fetch:
      src: "/tmp/goss/goss_{{ inventory_hostname }}.log"
      dest: "./log/goss_{{ inventory_hostname }}.log"
      flat: yes
```

実行します。

```
$ ansible-playbook goss_local.yml --diff -v
No config file found; using defaults
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] ****************************************************************************************************************************************************************************************************************************

TASK [copy goss files] **********************************************************************************************************************************************************************************************************************
changed: [localhost] => {"changed": true, "dest": "/tmp/", "src": "/opt/ansible/goss/files/goss"}

TASK [delegate the right to execute] ********************************************************************************************************************************************************************************************************
ok: [localhost] => {"changed": false, "gid": 0, "group": "root", "mode": "0755", "owner": "root", "path": "/tmp/goss/bin/goss", "secontext": "unconfined_u:object_r:user_home_t:s0", "size": 12414976, "state": "file", "uid": 0}

TASK [execute goss] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {"changed": false, "stdout": "1..11\nok 1 - Process: sshd: running: matches expectation: [true]\nok 2 - User: sshd: exists: matches expectation: [true]\nok 3 - User: sshd: uid: matches expectation: [74]\nok 4 - User: sshd: gid: matches expectation: [74]\nok 5 - User: sshd: home: matches expectation: [\"/var/empty/sshd\"]\nok 6 - User: sshd: groups: matches expectation: [[\"sshd\"]]\nok 7 - User: sshd: shell: matches expectation: [\"/sbin/nologin\"]\nok 8 - Group: sshd: exists: matches expectation: [true]\nok 9 - Group: sshd: gid: matches expectation: [74]\nok 10 - Service: sshd: enabled: matches expectation: [true]\nok 11 - Service: sshd: running: matches expectation: [true]\n", "stdout_lines": ["1..11", "ok 1 - Process: sshd: running: matches expectation: [true]", "ok 2 - User: sshd: exists: matches expectation: [true]", "ok 3 - User: sshd: uid: matches expectation: [74]", "ok 4 - User: sshd: gid: matches expectation: [74]", "ok 5 - User: sshd: home: matches expectation: [\"/var/empty/sshd\"]", "ok 6 - User: sshd: groups: matches expectation: [[\"sshd\"]]", "ok 7 - User: sshd: shell: matches expectation: [\"/sbin/nologin\"]", "ok 8 - Group: sshd: exists: matches expectation: [true]", "ok 9 - Group: sshd: gid: matches expectation: [74]", "ok 10 - Service: sshd: enabled: matches expectation: [true]", "ok 11 - Service: sshd: running: matches expectation: [true]"]}

TASK [fetch log] ****************************************************************************************************************************************************************************************************************************
changed: [localhost] => {"changed": true, "checksum": "1592662d878a81087e426b4bf250e155d3485674", "dest": "/opt/ansible/goss/log/goss_localhost.log", "md5sum": "663ccd7670bc02ce7da6910c6a2383bc", "remote_checksum": "1592662d878a81087e426b4bf250e155d3485674", "remote_md5sum": null}

PLAY RECAP **********************************************************************************************************************************************************************************************************************************
localhost                  : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

$ cat log/goss_localhost.log 
1..11
ok 1 - Process: sshd: running: matches expectation: [true]
ok 2 - User: sshd: exists: matches expectation: [true]
ok 3 - User: sshd: uid: matches expectation: [74]
ok 4 - User: sshd: gid: matches expectation: [74]
ok 5 - User: sshd: home: matches expectation: ["/var/empty/sshd"]
ok 6 - User: sshd: groups: matches expectation: [["sshd"]]
ok 7 - User: sshd: shell: matches expectation: ["/sbin/nologin"]
ok 8 - Group: sshd: exists: matches expectation: [true]
ok 9 - Group: sshd: gid: matches expectation: [74]
ok 10 - Service: sshd: enabled: matches expectation: [true]
ok 11 - Service: sshd: running: matches expectation: [true]
```

無事に実行結果をファイルに出力することができました。

Ansible から実行することで、ターゲットノード数が増えても並列実行ができるのではないかと思います。

## 感想

最後に、ここまで検討した感想を述べます：

1. インストールは簡単。12MBの単一実行ファイルだけで実行できる
1. validate は爆速。Ansible の力を借りれば並列実行できるのでさらに実行速度が上がる
1. validate のフォーマットは個人的にはtapがシンプルで読みやすい
1. goss.yaml に書いた順番通りにテストしてくれないので、テスト項目ごとにファイルを分けたほうがよさそう
1. リソースタイプは Serverspec と比較してとても少ないので、できる範囲で移行する、という割り切りが必要（とはいえ、ほとんどのものは移行できそう）


## 参考

* [GitHub - aelsabbahy/goss: Quick and Easy server testing/validation](https://github.com/aelsabbahy/goss)
    * [goss/manual.md at master · aelsabbahy/goss · GitHub](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md)
* [GitHub - indusbox/goss-ansible: Ansible module for Goss](https://github.com/indusbox/goss-ansible)