CloudFormationテンプレートをPythonで読み込む

CloudFormation（以下CFn）に使用するテンプレートファイルをプログラムで読み込んで色々するツールを作りたくなる事は往々にしてあると思います。
また、CFnには必須とも言える便利な組み込み関数が用意されています。

# 問題

通常、PythonでYAMLを読み込む場合はPyYAMLやruamel.yaml等を用いて解析しますが、
`!Ref`などのCFn独自の組み込み関数の短縮形を表す感嘆符("`!`")から始まる文字列は、タグ[^1]としてYAMLで定義されているため解析時に以下のようなエラーが発生してしまいます。

```
yaml.constructor.ConstructorError: could not determine a constructor for the tag '!Ref'
```

[^1]: [2.4. Tags - YAML Ain’t Markup Language (YAML™) version 1.2.2](https://yaml.org/spec/1.2.2/#24-tags)

# 解決策

aws-cliパッケージに、この問題を解決できる関数が実装されています。
[aws-cli/yamlhelper.py at develop · aws/aws-cli](https://github.com/aws/aws-cli/blob/develop/awscli/customizations/cloudformation/yamlhelper.py#L89-L101)
※AWS CLIの`aws cloudformation validate-template`コマンド[^2]にて使用されています。

[^2]: [validate-template — AWS CLI 1.22.62 Command Reference](https://docs.aws.amazon.com/cli/latest/reference/cloudformation/validate-template.html)

CFnにて正しく読み取れる形のYAML文字列を渡すと、順序付き辞書型（collections.OrderedDict）に変換してくれます。

```python:awscli.customizations.cloudformation.yamlhelper.py#yaml_parse
def yaml_parse(yamlstr):
    """Parse a yaml string"""
    try:
        # PyYAML doesn't support json as well as it should, so if the input
        # is actually just json it is better to parse it with the standard
        # json parser.
        return json.loads(yamlstr, object_pairs_hook=OrderedDict)
    except ValueError:
        loader = SafeLoaderWrapper
        loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, 
                               _dict_constructor)
        loader.add_multi_constructor("!", intrinsics_multi_constructor)
        return yaml.load(yamlstr, loader)
```

変換時には短縮形のCFn組み込み関数が完全な関数名の構文に置き換えられます。
※感嘆符`!`を`Fn ::`に変換したり`GetAtt`を標準的な配列へ変換したりなど
※コメントにあるように`Ref`や`Condition`は完全な関数名に`Fn ::`が付かないので、感嘆符`!`のみ取り除かれます。
このような変換処理をローダーに読ませて、PyYAMLで改めてロードしています。

```python:awscli.customizations.cloudformation.yamlhelper#intrinsics_multi_constructor

    # Some intrinsic functions doesn't support prefix "Fn::"
    prefix = "Fn::"
    if tag in ["Ref", "Condition"]:
        prefix = ""

    if tag == "GetAtt" and isinstance(node.value, six.string_types):
        # ShortHand notation for !GetAtt accepts Resource.Attribute format
        # while the standard notation is to use an array
        # [Resource, Attribute]. Convert shorthand to standard format
        value = node.value.split(".", 1)
```

## 補足
タグによってYAMLとして解析できなくて困るというのはCFnに限らずあるみたいです。
※解決策は同じで、コンストラクタを追加する形のようです。

```python
yaml.add_multi_constructor('!', lambda loader, suffix, node: None)
```

https://stackoverflow.com/questions/43765849/pyyaml-load-and-dump-yaml-file-and-preserve-tags-customtag

https://github.com/yaml/pyyaml/issues/86


# 実装

## 環境

```shell
$ python -V
Python 3.8.5
$ aws --version
aws-cli/1.19.57 Python/3.8.5 Linux/4.14.209-160.335.amzn2.x86_64 botocore/1.20.57
```

pipenvで管理していますが、awscliのバージョンは`1.19.2`です。

```json:pipfile.lock
        "awscli": {
            "hashes": [
                "sha256:7ca82e21bba8e1c08fef5f8c2161e1a390ddc19da69214eca8db249328ebd204",
                "sha256:8b79284e7fc018708afe2ad18ace37abb6921352cd079c0be6d15eabeabe5169"
            ],
            "index": "pypi",
            "version": "==1.19.2"
        },
```

## 例
```python:cfn-yaml_parse.py
import yaml

from awscli.customizations.cloudformation.yamlhelper import yaml_parse

if __name__ == '__main__':
    try:
        yaml_str = open('cfn-template.yaml').read()
        print(f'yaml_str：{yaml_str}')
        
        # YAML文字列を解析し、順序付き辞書型のオブジェクトを返却する
        yaml_dict = yaml_parse(yaml_str)
        print(f'yaml_dict：{yaml_dict}')
    except yaml.parser.ParserError as e:
        print(e)
        print('YAML形式として解析できない文字列です。（例：キーや:が無い）')
    except yaml.scanner.ScannerError as e:
        print(e)
        print('YAML形式として読み取れない値が含まれています。（例：CFnの組み込み関数の構文誤り）')
```

```yaml:cfn-template.yaml
Resources:
    ExampleVpc:
        Type: AWS::EC2::VPC
        Properties:
            CidrBlock: "10.0.0.0/16"
    IPv6CidrBlock:
        Type: AWS::EC2::VPCCidrBlock
        Properties:
            AmazonProvidedIpv6CidrBlock: true
            VpcId: !Ref ExampleVpc
    ExampleSubnet:
        Type: AWS::EC2::Subnet
        DependsOn: IPv6CidrBlock
        Properties:
            AssignIpv6AddressOnCreation: true
            CidrBlock: !Select [ 0, !Cidr [ !GetAtt ExampleVpc.CidrBlock, 1, 8 ]]
            Ipv6CidrBlock: !Select [ 0, !Cidr [ !Select [ 0, !GetAtt ExampleVpc.Ipv6CidrBlocks], 1, 64 ]]
            VpcId: !Ref ExampleVpc
```

```json:yaml_dict出力結果（整形済み）
OrderedDict([('Resources', 
    OrderedDict([
        ('ExampleVpc', 
            OrderedDict([
                ('Type', 'AWS::EC2::VPC'), 
                ('Properties', 
                    OrderedDict([
                        ('CidrBlock', '10.0.0.0/16')
                    ])
                )
            ])
        ), 
        ('IPv6CidrBlock', 
            OrderedDict([
                ('Type', 'AWS::EC2::VPCCidrBlock'), 
                ('Properties', 
                    OrderedDict([
                        ('AmazonProvidedIpv6CidrBlock', True), 
                        ('VpcId', 
                            {'Ref': 'ExampleVpc'})
                    ])
                )
            ])
        ), 
        ('ExampleSubnet', 
            OrderedDict([
                ('Type', 'AWS::EC2::Subnet'), 
                ('DependsOn', 'IPv6CidrBlock'), 
                ('Properties', 
                    OrderedDict([
                        ('AssignIpv6AddressOnCreation', True), 
                        ('CidrBlock', {
                            'Fn::Select': [
                                0, 
                                {'Fn::Cidr': [
                                    {'Fn::GetAtt': [
                                        'ExampleVpc', 'CidrBlock'
                                        ]
                                    }, 
                                    1, 
                                    8
                                ]}
                            ]
                        }), 
                        ('Ipv6CidrBlock', {
                            'Fn::Select': [
                                0, 
                                {'Fn::Cidr': [
                                    {'Fn::Select': [
                                        0, 
                                        {'Fn::GetAtt': [
                                            'ExampleVpc', 'Ipv6CidrBlocks'
                                            ]
                                        }
                                    ]}, 
                                    1, 
                                    64
                                ]}
                            ]
                        }), 
                        ('VpcId', {
                            'Ref': 'ExampleVpc'
                        })
                    ])
                )
            ])
        )
    ])
)])
```

# 参考

https://tociyuki.hatenablog.jp/entry/20121130/1354278408

https://stackoverflow.com/questions/43765849/pyyaml-load-and-dump-yaml-file-and-preserve-tags-customtag

https://medium.com/galactic1969/cloudformation%E3%81%AEyaml%E3%82%92pyyaml%E3%81%A7%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80-eafd5f41bf3c

https://github.com/yaml/pyyaml/issues/86

https://github.com/aws/aws-cli