【小ネタ】PythonでCloudFormationテンプレートをパースする時に組み込み関数の短縮系構文に邪魔されない方法

サーバーレスアプリの開発ではSAMテンプレート等でDynamoDBの定義を管理することが多いと思います。 ユニットテストを回す時にテンプレートからDynamoDBのテーブルを作成するためのちょっとしたテクニックをご紹介します。
2018.07.07

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

サーバーレス開発部@大阪の岩田です。

サーバーレスアプリの開発ではDynamoDBのテーブル定義をSAMやCloudFormationのテンプレート等で管理することが多いと思います。 DynamoDB LocalやLocalStackを使ってテストを回す際はテンプレートをパースして、よしなにテスト用のテーブルを作りたいところですが、単純にテンプレートをパースするとCloudFormationの組み込み関数の短縮系構文に邪魔をされることがあります。

組み込み関数の短縮系構文を使用していても、うまくテンプレートをパースする方法について調べたので、その手法についてご紹介します。

テスト用テンプレート

下記のテンプレートで試してみます。 DynamoDBのテーブルを1つ作るだけのシンプルなCloudFormationテンプレートです。 ParametersでStageNameというパラメータを定義しており、受け取った値をテーブル名に反映するようにしています。 実際のテンプレートだと、この中にさらにLambdaやAPI Gatewayの定義が乗っかってくることになります。

AWSTemplateFormatVersion: 2010-09-09
Description: Create DynamoDB Table
Parameters:
  StageName:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - stg
      - prd
    Description: Environment
Resources:
  TestTable:
    Type: "AWS::DynamoDB::Table"
    Properties:
      TableName: !Sub ${StageName}-test-table
      AttributeDefinitions:
        -
          AttributeName: "id"
          AttributeType: "S"
      KeySchema:
        -
          AttributeName: "id"
          KeyType: "HASH"
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5

普通にパースしてみる

テスト実行時に、先ほどのテンプレートをPythonから読み込んで、テスト用のテーブルを作成してみます。 なお、Pythonのバージョンは3.6.5を使用しています。

import yaml

def main():
  with open("dynamo.yml") as file:
    data = yaml.load(file)
  print(data)
  
  # テスト用のテーブルを作成する処理
  # ....

if __name__ == '__main__':
  main()

試しに実行すると・・・

Traceback (most recent call last):
  File "test.py", line 11, in <module>
    main()
  File "test.py", line 5, in main
    data = yaml.load(file)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/__init__.py", line 72, in load
    return loader.get_single_data()
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 37, in get_single_data
    return self.construct_document(node)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 46, in construct_document
    for dummy in generator:
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 398, in construct_yaml_map
    value = self.construct_mapping(node)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 204, in construct_mapping
    return super().construct_mapping(node, deep=deep)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 129, in construct_mapping
    value = self.construct_object(value_node, deep=deep)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 86, in construct_object
    data = constructor(self, node)
  File "/Users/xxxxxx/Documents/xxxxxx/lib/python3.6/site-packages/yaml/constructor.py", line 414, in construct_undefined
    node.start_mark)
yaml.constructor.ConstructorError: could not determine a constructor for the tag '!Sub'
  in "dynamo.yml", line 16, column 18

Fn::Subの短縮表記!Subが邪魔をしてYAMLとして正しくパース出来ません。

AWS CLIの力を借りてパースしてみる

先ほどのエラーに遭遇した時疑問に思ったのが、「AWS CLIのcloudformation validateコマンドはどうやってテンプレートを検証してるんだろう?」ということでした。 AWS CLIの中身を追いかけていくとyamlhelperというファイルの中に、yaml_parseというそれらしき関数を見つけました。 試しにこの関数を使ってパースしてみます。

from awscli.customizations.cloudformation.yamlhelper import yaml_parse

def main():
  with open("dynamo.yml") as file:
    data = yaml_parse(file.read())
  print(data)

  # テスト用のテーブルを作成する処理
  # ....
  
if __name__ == '__main__':
  main()
{'AWSTemplateFormatVersion': datetime.date(2010, 9, 9), 'Description': 'Create DynamoDB Table', 'Parameters': {'StageName': {'Type': 'String', 'Default': 'dev', 'AllowedValues': ['dev', 'stg', 'prd'], 'Description': 'Environment'}}, 'Resources': {'TestTable': {'Type': 'AWS::DynamoDB::Table', 'Properties': {'TableName': {'Fn::Sub': '${StageName}-test-table'}, 'AttributeDefinitions': [{'AttributeName': 'id', 'AttributeType': 'S'}], 'KeySchema': [{'AttributeName': 'id', 'KeyType': 'HASH'}], 'ProvisionedThroughput': {'ReadCapacityUnits': 5, 'WriteCapacityUnits': 5}}}}}

今度はパース出来ました! 出力結果をよく見ると、

{'Fn::Sub': '${StageName}-test-table'}

と短縮表記!Subで書いていたところがFn::Subに展開されています。 これでパース出来るようになったので、後はテストのFixture内でパースした内容からテーブルを作成すればOKです!

まとめ

以上 PythonでCloudFormationテンプレートをパースする時のちょっとしたテクニックでした。 テストコードの中でいちいちテーブル定義を書いてしまうと、テーブル定義に変更があった場合にテストコードの修正が漏れ、本来落ちるべきテストが通ってしまう。 といった事態も想定されます。 今回紹介したようなテクニックをうまく活用して、設定を一元管理出来れば幸せになれるのではないでしょうか。