YAMLファイルはJSON Schemaでバリデーションしよう

## YAMLを検証しないと本番がヤバい

Ansibleなど、環境ごとに下記のようなYAML設定ファイルを作成することはよくあると思います。

```yaml:ansible/inventories/staging/group_var/all/foobar.yml
# ステージング環境

FOO_BAR_PARAM: "hello staging!"
BAR_BAZ_PARAM: 100
```

そして、ほとんどのプロジェクトで **人力で内容に間違いがないように頑張って設定を入力＆チェックしている**ことと思います。（そしてミスが発生します）

```yaml:ansible/inventories/production/group_var/all/foobar.yml
# 本番環境

FOO_BAR_PARAM: "hello production!"
# おや、値の型に間違いが？！
BAR_BAZ_PARAM: "enabled"
```

試験環境であれば上記のような間違いも取り返しがつくのですが、 **本番環境は大概デプロイ一発勝負であるが故に、ミスすると取り返しが付きません。**

頑張ってチェックしても、人力をすり抜けるミスがありうるというのは非常に心臓に悪いです。

**あらかじめ想定した型（スキーマ）がある以上、YAMLファイルもバリデーションすべきです。**


## YAMLのバリデーションに使える道具はあるか？

YAMLのバリデーションというのは、そこまでメジャーな関心事ではないらしく、専用のメジャーなスキーマ技術がぱっと出てきません。

そこでいろいろ調べた結果、下記がとりあえず候補として上がりました。

- [JSON Schema](https://json-schema.org/)
    - 良い点
        - **VS Code** でサポートされている
            - [RedHat からリリースされているプラグイン](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml)で可能です
            - **リアルタイムで補完と検証が効く**ので、かなり体験がいいです
        - **ツール、処理系、ノウハウが豊富**
        - **識者が多い**
        - **スキーマに注力**している
    - 悪い点
        - 書式が**冗長**
        - そこまで**構造的な表現力は豊かではなさそう**
            - とはいえ、そこまで複雑なものを設定ファイルで扱わないほうが賢明では？
            - `"format": "ipv4"` など特定ユースケースにピッタリな書式があるのは良い
- [CUE](https://cuelang.org/)
    - 良い点
        - TypeScriptのように**簡潔**に型を表現できる
        - **複雑な型**を表現できる
        - YAMLに限らず**応用範囲が広い**
        - スキーマ以外に**できることが多い**（デメリットでもある）
    - 悪い点
        - **ツール、処理系が少ない**
        - **識者が少ない**
        - YAMLのタグ(Ansibleであれば `!vault` など)で **死ぬ** (後述)

上記いろいろ四苦八苦した結果、**JSON Schemaがベター**という結論になりました。


## JSON Schemaがなぜベターだったか？

まずもって「**JSON SchemaがJSONではなくYAMLのバリデーションに使えるのか？**」という疑問があると思いますが、**ちゃんと使えます！** 数値・文字列・リスト・ディクショナリなど基本的にJSONとYAML両者が表現する対象は変わらないからです。

また、 VS Codeでの体験もJSON SchemaはCUEより良質でした。リアルタイムな入力の補完・検証が良い感じです。

決定的だったのが、**YAML特有の下記のようなYAMLタグの扱いでした。**

```yaml
# CUE: YAML読み込み時のエラーで死ぬ (何も検証されない and 調べた限りどうしようもないっぽい)
# JSON Schema: (処理系によるだろうが) そこだけエラーになる or YAMLタグプロセッサを外部注入できる
the_secret: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      62313365396662343061393464336163383764373764613633653634306231386433626436623361
      6134333665353966363534333632666535333761666131620a663537646436643839616531643561
      63396265333966386166373632626539326166353965363262633030333630313338646335303630
      3438626666666137650a353638643435666633633964366338633066623234616432373231333331
      6564
```

上記のようなYAMLタグは、CUEは今の所エラーでドキュメントごと扱えず死んでしまいますが、**JSON Schemaはエラーになりがらも [^1] 扱うことができました**。これがJSON Schemaを選定する理由として決定的でした。

[^1]: 処理系によるとは思いますが、VS Codeの拡張機能だと `Unresolved tag: !vault` と出るだけで他はちゃんと検証してくれます。


## VS CodeでYAMLの補完・バリデーションが効くようにする

VS Codeにて[redhat.vscode-yaml](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) をインストールし、 `.vscode/settings.json` に下記のようにJSONスキーマとYAMLの紐付けを記述すればリアルタイムに検証・補完をしてくれるようになります。

```json:.vscode/settings.json
  "yaml.schemas": {
    "path/to/foobar.schema.json": [
        "ansible/inventories/*/group_var/all/foobar.yml"
    ]
  }
```


## YAMLからJSONスキーマを自動生成する

VS Codeでバリデーションが効くようになっても、肝心のJSON Schemaを用意しなければ話になりません。

コードリポジトリが小さい場合一つ一つ手でJSON Schemaを書いてもいいですが、ある程度の規模であれば下記のようなスクリプトでYAMLファイルから自動生成したものを手直ししたほうが効率的です。

```py:generate_json_schema_for_yaml.py
# あらかじめ以下を実行して依存性をインストールしておくこと
# $ pip3 install pyyaml genson

import json
import yaml
from genson import SchemaBuilder

def custom_tag_constructor(loader, node):
    return loader.construct_scalar(node)

# !vault などのYAMLタグのプロセッサを適当に定義する
yaml.SafeLoader.add_constructor('!vault', custom_tag_constructor)

# src_yml を読み込み dest_json に JSON Schema を出力する
def convert(src_yml, dest_json):
    with open(src_yml, "r") as src_yml_file:
        yaml_data = yaml.safe_load(src_yml_file)
    builder = SchemaBuilder()
    builder.add_object(yaml_data)
    with open(dest_json, "w") as file:
        json.dump(builder.to_schema(), file, indent=2)

convert(
    "ansible/inventories/production/group_var/all/foobar.yml",
    "path/to/foobar.schema.json")
```


## CIでYAMLを検証する

VSCodeだけでYAMLをチェックしても片手落ちです。CIでもチェックできるようにPythonスクリプトを用意しましょう！

```py:validate_yaml_by_json_schema.py
# あらかじめ以下を実行して依存性をインストールしておくこと
# $ pip3 install pyyaml jsonschema

import os
import yaml
import jsonschema
import json
import glob
from jsonschema import RefResolver

def custom_tag_constructor(loader, node):
    return loader.construct_scalar(node)

# !vault などのYAMLタグのプロセッサを適当に定義する
yaml.SafeLoader.add_constructor('!vault', custom_tag_constructor)

# YAML を JSON Schema で検証する
def validate_yaml(schema_file_path, yaml_file_path):
    # JSON Schema のロード
    with open(schema_file_path, "r") as schema_file:
        schema = json.load(schema_file)

    # JSON Schema から外部ファイル参照があった場合にスキーマと同じディレクトリを探索するようにする
    schema_directory = os.path.abspath(os.path.dirname(schema_file_path))
    resolver = RefResolver(base_uri=f"file://{schema_directory}/", referrer=schema)

    # YAML のロード
    with open(yaml_file_path, "r") as yaml_file:
        yaml_data = yaml.safe_load(yaml_file)

    # 失敗した場合 jsonschema.exceptions.ValidationError がスローされる
    jsonschema.validate(instance=yaml_data, schema=schema, resolver=resolver)

# 実際は .vscode/settings.json からスキーマとYAMLファイルのひも付きを読んだほうがいい
for yaml_file_path in glob.glob("ansible/inventories/*/group_var/all/foobar.yml", recursive=True):
    validate_yaml(
        "path/to/foobar.schema.json",
        yaml_file_path)
```

## まとめ

**何らかのルールがあるYAMLファイルは JSON Schemaでチェックするのがおすすめです！**