AWS CloudFormationのYAMLファイルでCommaDelimitedListの空判定をしたい

# TL; DR

`!Join` がここで使えるんだ！



# はじめに

CloudFormationには、パラメーターの型に `List<Number>` 型と `CommaDelimitedList` 型が存在します。
これらは、パラメーター値として `,`（カンマ）区切りの数値または文字列を入力すると、カンマで分割したリストに変換してくれるというものです。

>List&lt;Number&gt;
>
>カンマで区切られた整数または浮動小数点値の配列。AWS CloudFormation は、このパラメータを数値として検証しますが、テンプレート内の他の場所で使用した場合には (Ref 組み込み関数を使用した場合など) 一連の文字列として扱います。
>たとえば、"80,20" と指定し、Ref を使用した場合には ["80","20"] となります。
>
>---
>
>CommaDelimitedList
>
>カンマで区切られたリテラル文字列の配列。文字列の合計数は、カンマの合計数よりも 1 つ多いはずです。また、各メンバー文字列の前後の空白は削除されます。
>たとえば、"test,dev,prod" と指定し、Ref を使用した場合には ["test","dev","prod"] となります。

[パラメータ - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html#parameters-section-structure-properties)

これを使うことで、設定したい項目数を動的に変更したい場合[^count]にも、表現の幅が広がります。


# クロスアカウントアクセス

1例として、S3バケットのクロスアカウントアクセスに必要なAWSアカウントIDが挙げられます。

[^count]: 項目数を動的に構築する手法は[カウントマクロ](https://github.com/awslabs/aws-cloudformation-templates/tree/master/aws/services/CloudFormation/MacrosExamples/Count)が有名ですね。これだけのためにLambdaを作りたくはないですが......

クロスアカウントアクセスとは、とあるAWSアカウントのS3バケットを、他AWSアカウントから見るようにできる設定のことを意味します。
設定方法は省略しますが、下記が参考になります。

[S3バケットのクロスアカウントアクセス - Qiita](https://qiita.com/libra_lt/items/4cb3e656f754c478a32a)

```yaml:クロスアカウントアクセス時のCFn.yaml
Resources:
  Account1BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties: 
      Bucket: account1-bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - 000000000000 # クロスアカウントアクセス可能なAWSアカウント
                - 123456789012 # クロスアカウントアクセス可能なAWSアカウント
            Action:
              - s3:GetObject
              - s3:ListBucket
            Resource:
              - arn:aws:s3:::account1-bucket
              - arn:aws:s3:::account1-bucket/*
```

`Principal` ハッシュにおける `AWS` キーの値として、 複数のAWSアカウントを指定できます。
これにより、 Account1 の account1-bucket バケットを、 000000000000 アカウントと 123456789012 アカウントが GetObject および ListBucket できます。

`AWS` キーの値として、パラメーター `CommaDelimitedList` 型の値を参照することで、下記のように**1つ以上**のAWSアカウントを設定できます。

```yaml:パラメーターによるクロスアカウントアクセス時のCFn.yaml
Parameters:
  AccountIdList:
    Type: CommaDelimitedList

Resources:
  Account1BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties: 
      Bucket: account1-bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Ref AccountIdList # クロスアカウントアクセス可能なAWSアカウントのリスト
            Action:
              - s3:GetObject
              - s3:ListBucket
            Resource:
              - arn:aws:s3:::account1-bucket
              - arn:aws:s3:::account1-bucket/*
```

[AWS::S3::BucketPolicy - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-s3-policy.html)

これは嬉しいですよね。



# 問題点

しかし、1つだけ問題があります。
それは、**0つ以上**のAWSアカウントを設定できない点です。

もし上記のCFn.yamlファイルを実行する際、パラメーターに空文字を入力する場合（または、Defaultを空文字にした上でパラメーターを与えない場合）、正しく動作しません。
これは、 `Principal` ハッシュにおける `AWS` キーの値に空文字を入力できないためです。

これを防ぐためには、 Condition によってリソース自体を除去するしか方法はありません。
しかし、下記の場合はエラーとなります。
`every Fn::Equals object requires a list of 2 string parameters.` と言われてしまうためです。

```yaml:Conditionを利用しようとしたクロスアカウントアクセス時のCFn.yaml
Parameters:
  AccountIdList:
    Type: CommaDelimitedList
    Default: ''

Condition:
  IsEmpty:
    !Equals [ !Ref AccountIdList, [] ] # エラー！

Resources:
  Account1BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Condition: IsEmpty # false の場合に Account1BucketPolicy リソース自体を作成しないようにしたい
    Properties: 
      Bucket: account1-bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Ref AccountIdList # クロスアカウントアクセス可能なAWSアカウントIDのリスト
            Action:
              - s3:GetObject
              - s3:ListBucket
            Resource:
              - arn:aws:s3:::account1-bucket
              - arn:aws:s3:::account1-bucket/*
```



# 解決策

`!Equals` 組込み関数は、 ~~2つの値が両方とも String 型でなければならない~~ リスト同士の比較はできないそうです。
~~ドキュメントにはそんなこと一言も書いてないどころか、任意の型で判定できるかのように書いてあるんですけどね。~~

>value
>
>比較する任意の型の値です。
>
>[条件関数 - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-equals)

これを解決する手法の1つに、 `!Join` 組込み関数によるリストの文字列への変換があります。
~~正直、 `!Join` が役立つ時が来ると思ってませんでした。~~

> 次の例は、 `"a:b:c"` を返します。
>
> JSON
>
> ```json
> "Fn::Join" : [ ":", [ "a", "b", "c" ] ]
> ```
>
> YAML
>
> ```yaml
> !Join [ ":", [ a, b, c ] ]
> ```
>
> [Fn::Join - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-join.html)

これで解決！と思いきや、実はもう一つ、隠れた理由があります。
それは、 `Principal` ハッシュにおける `AWS` キーの値に空文字を設定したままである問題を解決できていないためです。

この問題は、ポリシードキュメントの文法規則の穴を突くことで、解決できます。
すなわち、自身のAWSアカウントIDを代入するのです。
そもそもの原因は、ポリシードキュメントでは、事前にAWSアカウントIDが存在していることを確認するような挙動をしています。
自身のAWSアカウントIDを代入することで、ポリシードキュメントを騙し、 Condition により、ダミーポリシー自体を除去できます。

まとめると、下記の通りです。

```diff:修正後のCFn.yaml
 Parameters:
   AccountIdList:
     Type: CommaDelimitedList
     Default: ''

 Condition:
   IsEmpty:
     !Equals [ !Ref AccountIdList, '' ] # エラー！
+    !Equals [ !Join [ ',', !Ref AccountIdList ], '' ]

 Resources:
   Account1BucketPolicy:
     Type: AWS::S3::BucketPolicy
     Condition: IsEmpty # false の場合に Account1BucketPolicy リソース自体を作成しないようにしたい
     Properties: 
       Bucket: account1-bucket
       PolicyDocument:
         Version: 2012-10-17
         Statement:
           - Effect: Allow
             Principal:
-              AWS: !Ref AccountIdList # クロスアカウントアクセス可能なAWSアカウントIDのリスト
+              AWS:
+                - !If
+                  - IsEmpty
+                  - !Ref AWS::AccountId　# 自身のAWSアカウントID
+                  - !Ref AccountIdList # クロスアカウントアクセス可能なAWSアカウントIDのリスト
             Action:
               - s3:GetObject
               - s3:ListBucket
             Resource:
               - arn:aws:s3:::account1-bucket
               - arn:aws:s3:::account1-bucket/*
```

- `IsEmpty` の `!Equals` 組込み関数内で、 `!Join` を利用する
- `Principal` ハッシュにおける `AWS` キーの値で `!If` を利用し、リストが空の場合は自身のAWSアカウントIDを設定する

これにより、 CommaDelimitedList の空判定ができました。
また、ついでに不要なポリシーの除去もできました。

[CloudFormation Condition on CommaDelimitedList : aws](https://www.reddit.com/r/aws/comments/5lmf7t/cloudformation_condition_on_commadelimitedlist/)


## ちなみに

別のステートメントがポリシードキュメント内に存在する場合は、 Condition によるリソース除去ではなく、 `!If` 組込み関数による空ステートメントの挿入だけで良いです。
空ステートメントには、 `AWS::NoValue` 疑似変数を利用します。
`AWS::NoValue` は、ステートメントの定義ハッシュ自体を除去するため、自身のAWSアカウントIDを設定するという抜け道を利用しなくても、ポリシードキュメントの文法を問題なく通過できます。

```yaml:静的に定義するステートメントが別に存在する場合のCFn.yaml
Parameters:
  AccountIdList:
    Type: CommaDelimitedList
    Default: ''

Condition:
  IsEmpty:
   !Equals [ !Join [ ',', !Ref AccountIdList ], '' ]

Resources:
  Account1BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties: 
      Bucket: account1-bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          # 静的に定義するステートメント
          - Effect: Allow
            Action: *
            Resource: *

          # 動的に定義したいステートメント
          - !If
            - IsEmpty
            - !Ref AWS::NoValue # リストが空の場合
            - Effect: Allow # リストが空じゃない場合
              Principal:
                AWS: !Ref AccountIdList # クロスアカウントアクセス可能なAWSアカウントIDのリスト
              Action:
                - s3:GetObject
                - s3:ListBucket
              Resource:
                - arn:aws:s3:::account1-bucket
                - arn:aws:s3:::account1-bucket/*
```

> AWS::NoValue
>
> `Fn::If` 組み込み関数の戻り値として指定すると、対応するリソースプロパティを削除します。
>
> [擬似パラメーター参照 - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html#cfn-pseudo-param-novalue)



# おわりに

CFnを触り続けてひと月が経過しましたが、宣言型文法で動的処理をするのは骨が折れますね。
ただ、CFnはAWSの全てのサービスを大雑把でも理解していないと触れないと思うので、AWSに強くなったひと月だったと思います。

皆さんも、CommaDelimitedListで~~抜け道~~面白い使い方を見つけてみてください。