設定ファイルを受取りjinja2で生成したJSONファイルがvalidなJSONかチェックしたい


設定ファイルを受取りjinja2で生成したJSONファイルがデプロイの際に使われる。この生成されたJSONファイルがvalidなJSONかチェックしたい。

## 利用しているコマンド

[j2cli](https://github.com/kolypto/j2cli)みたいなイメージ。

以下の２つのファイルを使う。

- 設定ファイル(.json)
- templateファイル(.json.j2)

例えば、設定ファイルが以下のときに、

```json
{
  "name": "staging"
}
```

このようなtemplateを使う。

```json
{
  "app-name": "{{name}}-app",
  "batch-name": "{{name}}-batch"
}
```

結果として以下のようなJSONを出力する。これが実際の設定ファイルとして使われる。


```json
{
  "app-name": "staging-app",
  "batch-name": "staging-batch"
}
```

dev的な設定の場合は以下のような出力になるかもしれない。

```json
{
  "app-name": "dev-app",
  "batch-name": "dev-batch"
}
```

## 問題

生成されたJSONがinvalidな場合がある。本番にデプロイされたタイミングでようやく気づくということになるとかなしい。事前にチェックしたい。

## やること

方針は以下。

1. templateをjinja2でemit
2. emitされたJSONを再度json.loads
3. エラーが発生したらinvalidなJSONなはず

pythonのコードにするとこんな感じ。

```python
import jinja2
import json


env = jinja2.Environment(loader=jinja2.FileSystemLoader(["."]), undefined=jinja2.StrictUndefined)
t = env.get_or_select_template(template_path)
json.loads(t.render(**config))  # invalidならエラー
```

ただいくつか注意点がある。

### 注意点1 期待した値が設定ファイル中に含まれてない場合

```JSON
{"foo": "foo-{{name}}"}
```

このようなtemplateに何も設定せずにjinja2でemitすると以下の様になるがエラーにならない。

```JSON
{"foo": "foo-"}
```

これはjinja2のundefinedの設定を変えれば検知できる。`undefined=jinja2.StrictUndefined` を付けてあげれば `UndefinedError` が発生するようになる。


### 注意点2 templateに "}" が過剰に含まれていた場合

templateに不備があり、"}" が過剰に含まれている場合がまずい。生成されるJSONはJSONとしてはvalidではあるけれど。期待した出力ではない。


誤って以下のようなtemplateを書いてしまった場合。

```
{
  "app-name": "{{name}}}-app"
}
```

以下はvalidなJSONではあるけれど。期待した出力ではない。

```JSON
{
  "app-name": "staging}-app"
}
```

一応dictのvalueの値もチェックすると良い。

## jinja2でemitした結果がvalidなJSONか調べてくれるスクリプト

以上を踏まえてjinja2でemitした結果がvalidなJSONか調べてくれるスクリプトを作った。こんな感じで使う。environという変数が存在しなかった場合には以下の様にエラーになる。したの利用例は `environ` という名前の設定が設定ファイル中二存在していなかった場合のエラー。

```bash
$ python check.py --conf-dir conf/ --template-dir template/ || echo ng
template/back.json.j2(conf/master.json): UndefinedError 'environ' is undefined
template/back.json.j2(conf/production.json): UndefinedError 'environ' is undefined
template/back.json.j2(conf/rook.json): UndefinedError 'environ' is undefined
ng
```

実装は以下のようなもの。

```python
import sys
import os.path
import argparse
import jinja2
import json


def validate_conf(d, path=None):
    path = path or []
    if hasattr(d, "items"):
        for k, v in d.items():
            path.append(k)
            validate_conf(v, path=path)
            path.pop()
    elif isinstance(d, (list, tuple)):
        for i, x in enumerate(d):
            path.append(i)
            validate_conf(x, path=path)
            path.pop()
    elif isinstance(d, str):
        v = d
        if "{" in v:
            raise ValueError("invalid value: '{{' is included. path={}".format(path))
        if "}" in v:
            raise ValueError("invalid value: '}}' is included. path={}".format(path))
    return d


def check(env, config, template_path):
    t = env.get_or_select_template(template_path)
    data = json.loads(t.render(**config))
    return validate_conf(data)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--conf-dir", required=True)
    parser.add_argument("--template-dir", required=True)
    args = parser.parse_args()

    env = jinja2.Environment(loader=jinja2.FileSystemLoader(["."]), undefined=jinja2.StrictUndefined)
    status = 0
    for root, _, conf_files in os.walk(args.conf_dir):
        for conf_file in conf_files:
            try:
                confpath = os.path.join(root, conf_file)
                with open(confpath) as rf:
                    conf = json.load(rf)
            except Exception as e:
                sys.stderr.write("{confpath}: {e.__class__.__name__} {e}\n".format(confpath=confpath, e=e))
                status = 1
                continue

            for root2, _, template_files in os.walk(args.template_dir):
                for template_file in template_files:
                    try:
                        filepath = os.path.join(root2, template_file)
                        check(env, conf, filepath)
                    except Exception as e:
                        sys.stderr.write("{filepath}({confpath}): {e.__class__.__name__} {e}\n".format(
                            confpath=confpath, filepath=filepath, e=e)
                        )
                        status = 1
    sys.exit(status)


if __name__ == "__main__":
    main()
```