CloudFormationのスタックのdiffをGithubで参照できるようにする
続きです。 bluepixel.hatenablog.com
自前のGithub Actionを作っていきます。
ワークフローの内容
CloudFormationで作成したスタックを更新する際、変更セットというものを作る必要があります。
この変更セットには、どのリソースの何が変更されるのが、置換が必要な操作なのかなどの情報を含まれているのですが、CLIで確認するには使い勝手が悪く、また、マネジメントコンソール上では確認できますが、変更されるプロパティの詳細までは表示されません。
まずこれをGithub上でわかりやすく確認できるようにしたいです。
また、IaC(Infrastructure as Code)においてネックとなるレビュー問題を解決します。CloudFormationの場合は、テンプレートとなるjsonまたはymlファイルを目視で確認する、または実際に変更セットを作ってみて内容を確認するという作業が発生します。これをGithub Actionsでオートメーション化して、プルリクがオープンされたのをトリガーに自動で変更内容を貼り付けるようにしたいと思います。
完成系
先に完成のイメージを貼りました。 見た目はマネジメントコンソールをなるべく模すようにしています。
実装
最初にワークフローの流れを整理します。
- チェックアウト
- AWS認証
- 変更セットの作成 (
creat-change-set
) - 作成された変更セットの取得(
describe-change-set
) - diffの抽出・整形
- 変更セットの削除(
delete-change-set
) - diffをプルリクにコメント
赤字の部分が今回マーケットプレイスにリリースしたGithub Actionsになります。
なぜ赤字の部分だけかというと、それはGithub Actionsの設計思想に関係しています。
ワークフローとは、個々のタスクをパーツとして柔軟に組み合わせて構築するものです。マーケットプレイスにリリースされているアクションはそのパーツになります。そのため、1つのアクションがいろいろやりすぎているとワークフローに組み込みづらくなり、ユースケースが限定されてしまいます。
それぞれのパーツが入力と出力を介してゆるくつながる、言い換えれば任意につなげられる疎な状態を保つことが、使いやすいアクションの条件となります。
今回で言えば、AWS認証は公式で aws-actions/configure-aws-credentials が提供していますし、ファイルのダウンロード・アップロードはアーティファクト機能を通して実現できます。プルリクへのコメント作成もあらかじめ組み込まれている環境変数GITHUB_TOKEN
を使えば、別でアクセストークンを用意する必要もありません。
このあたりの境界や責務の範囲を理解すると、うまくワークフローを設計したり、カスタムアクションを作成することができます。
Dockerfile
スクリプトをpythonで書いているのでpythonイメージをベースに、aws-cli, jq, less をインストールしてます。
FROM python:3 RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ && unzip awscliv2.zip \ && ./aws/install RUN apt-get update \ && apt-get install -y less jq COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]
Githubがホスティングしているランナー(ex. Ubuntu 18.04.4 LTS)にはすでに基本的な言語とソフトウェアがインストールされているんですが、aws-cliが1系だったり、やっぱりDocker化しないと手元で開発がしにくかったりするのでDockerを選んでいます。
entrypoint.sh
変更セットの作成
diffが見たいだけなんですが、そういうAPIは用意されていません。
一時的な変更セットを作成して、diffを保存して、削除するという形をとっています。
変更セット名は、ユーザーが別で色々管理しているものがあるかもしれないのでランダムにUUIDを生成しています。
OSによりますがcat /proc/sys/kernel/random/uuid
で取得できます。
小ネタですが変更セット名の先頭は英文字である必要があるために"a$(cat /proc/sys/kernel/random/uuid)"
としています。
#!/bin/sh -l uuid="a$(cat /proc/sys/kernel/random/uuid)"
スタック名とテンプレートファイルはパラメータで指定します。
ドキュメントがなくて分かりづらいんですが、ワークフローからwith
で受け取ったパラメータは内部で$INPUT_*
で参照できるようになっています。
例えばstack_name:
は$INPUT_STACK_NAME
になります。
aws cloudformation create-change-set --stack-name $INPUT_STACK_NAME --template-body file://$INPUT_TEMPLATE_BODY --change-set-name=$uuid if [ $? -ne 0 ]; then echo "[ERROR] failed to create change set." exit 1 fi
AWSの認証に失敗したりスタックが存在しなかったりする場合は、スタックの作成に失敗するので明示的にexitします。0以外のコードを返せばワークフロー側で失敗として認識されます。
変更セットの確認
作成した変更セットからdiffを抽出します。
具体的にはdescribe-change-set
のレスポンスの"Changes"
に含まれている部分です。
すぐに投げてしまうとステータスがまだ作成中の場合があるので、何回かポーリングします。
for i in `seq 1 5`; do aws cloudformation describe-change-set --change-set-name=$uuid --stack-name=$INPUT_STACK_NAME --output=json > $uuid.json status=$(cat $uuid.json | jq -r '.Status') if [ ${status} = "CREATE_COMPLETE" ] || [ ${status} = "FAILED" ]; then break else echo "change set is now creating..." sleep 3 fi done
ステータスは CREATE_IN_PROGRESS
, CREATE_COMPLETE
, FAILED
の3種類です。
変更点がない場合、ステータスは FAILED
となります。
それはそれで変更点なしとして表示したいので FAILED
でも作成完了と見なすようにしました。
変更セットの削除
後片付けです。
レビュー後そのまま適用したいというニーズもあるかもしれないのでここオプションにしようかなあとか考えていたりする🤔
aws cloudformation delete-change-set --change-set-name=$uuid --stack-name=$INPUT_STACK_NAME if [ $? -ne 0 ]; then echo "[ERROR] failed to delete change set." fi
結果の整形
jsonに保存した結果をpythonスクリプトでごにょごにょして出力します。
::set-output
はGihub Actionの組み込み関数で、ここに値を詰めるとワークフロー内の別のステップから参照できるようになります。
先に述べたように、様々なユースケースに応じて利用できるように整形前の素のjsonも一応突っ込んでいます。
文字列だけでなくファイルもアーティファクトとして使えます。
文字列として扱う場合は、multilineが扱えないのでjq
の-c
オプションで圧縮する必要があります。
result=$(cat $uuid.json | jq -c) echo "::set-output name=change_set_name::$uuid" echo "::set-output name=result::$result" echo "::set-output name=result_file_path::$uuid.json" python pretty_format.py $uuid $INPUT_STACK_NAME echo "::set-output name=diff_file_path::$uuid.html"
# pretty_format.py import json import sys class ChangeSet: changes = None def __init__(self, changes): self.changes = changes def action(self): action = self.changes['ResourceChange']['Action'] color = "" if self.changes['ResourceChange']['Action'] == "Modify": color = "<img src=\"https://placehold.it/12/0073bb/0073bb?text=+\" />" elif self.changes['ResourceChange']['Action'] == "Add": color = "<img src=\"https://placehold.it/12/1d8102/1d8102?text=+\" />" elif self.changes['ResourceChange']['Action'] == "Remove": color = "<img src=\"https://placehold.it/12/d13212/d13212?text=+\" />" return "%s %s" % (color, action) def logical_resource_id(self): return self.changes['ResourceChange']['LogicalResourceId'] def physical_resource_id(self): if 'PhysicalResourceId' in self.changes['ResourceChange']: return self.changes['ResourceChange']['PhysicalResourceId'] else: return "-" def resource_type(self): return self.changes['ResourceChange']['ResourceType'] def replacement(self): if 'Replacement' in self.changes['ResourceChange']: return self.changes['ResourceChange']['Replacement'] else: return "-" def details(self): arr = [] for d in self.changes['ResourceChange']['Details']: if d['Target']['Attribute'] != 'Properties': continue arr.append("- %s" % d['Target']['Name']) return "<br>".join(arr) if __name__ == '__main__': data = {} with open("%s.json" % sys.argv[1]) as f: data = json.load(f) body = "<h1>Change set</h1><h2>Stack Name: %s</h2><br>" % sys.argv[2] if len(data['Changes']) > 0: body += "<table><tr><th>Action </th><th>ID</th><th>Type</th><th>Replacement</th><th>Changed Properties</th></tr>" for c in data['Changes']: body += "<tr>" change_set = ChangeSet(c) body += "<td>%s</td>" % change_set.action() body += "<td>%s</td>" % change_set.logical_resource_id() # cols.append(change_set.physical_resource_id()) body += "<td>%s</td>" % change_set.resource_type() body += "<td>%s</td>" % change_set.replacement() body += "<td>%s</td>" % change_set.details() body += "</tr>" body += "</table>" else: body += "no change." with open("%s.html" % sys.argv[1], mode='w') as f: f.write(body)
整形部分にはいくつかハックを施しています。
見慣れているマネジメントコンソールっぽく表示するのが見る側にとって負担がないので、まずそれを実現するために色をつけます。
この色を付けるハックはStack Overflowで見つけた方法で、プレースホルダー画像を生成してくれるサイトを外部画像として参照するというものです。
背景色と文字色を同じにして適当なテキストを設定すればぽく見えます。
次に物理リソースIDを消します。長くて表が見づらくなるしそんなに需要はないと判断したためです。
代わりに、変更されるプロパティを追加します。
マネジメントコンソールには表示されないものなんですが、実はAPIでは取得できるのでリストします。本来これが一番重要な情報だったりすると思うんだが...
ちなみに["Target"]["Metadata"]
と["Target"]["Tag"]
は無視してます。
これもあまり需要はないかと思ったので。
最後にこれらを表形式でまとめあげてファイルに書き出すんですが、マークダウンではなくHTMLとしたのには明確な理由があり、
最終的にプルリクにコメントとして投げる時に、JSONのボディに指定する値がmultiline stringだと大いに問題があります。
Githubの仕様としても、改行コードのエンコード・デコードがややこしく、::set-output
を通すとまた変換が行われたり、うまく改行を保ったままポストすることができませんでした。
最終的にたどり着いたのがHTMLで、さらにファイルの中身をcat
とxargs echo
で参照する方法です(catだと失敗する)
run: | curl -X POST \ -H "Authorization: token ${GITHUB_TOKEN}" \ -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \ ${URL}
自分で実装しておいてあれなんだが、なんで ` curl -d "{\"body\": \"$(cat ${FILE_PATH})\"}" ` だとだめで、 `
— 青いエンジニア🦋 (@itmono_sakuraya) 2020年4月28日
curl -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" ` だとうまくいくのかわからん
テスト
テスト用のワークフローを作ります。
整形したデータはアーティファクトとして利用しています。
コメントのポストに必要なGithubトークンはすでに用意されているものを使います。
on: pull_request: types: [opened, synchronize] jobs: list-change-stack: runs-on: ubuntu-latest name: list cfn stack change set steps: - name: checkout uses: actions/checkout@v2 - name: configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 - name: describe change set id: describe-change-set uses: ./ with: stack_name: omochi template_body: after.cf.yml - name: archive diff uses: actions/upload-artifact@v1 with: name: diff path: ${{ steps.describe-change-set.outputs.diff_file_path }} - name: Download diff markdown uses: actions/download-artifact@v1 with: name: diff - name: Post comments env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} URL: ${{ github.event.pull_request.comments_url }} FILE_PATH: ${{ steps.describe-change-set.outputs.diff_file_path }} run: | cat ${FILE_PATH} | xargs echo curl -X POST \ -H "Authorization: token ${GITHUB_TOKEN}" \ -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \ ${URL}
リリース
できたものをマーケットプレイスにリリースします。 特に審査もなくサクッと出せます。
README.mdとaction.yml
のメタデータをしっかり書きましょう。
アイコンはいくつか用意されているものから選べます。
# action.yml name: 'describe-cfn-change-set' description: 'describe cfn change set' author: 'sakuraya (@Blue-Pix)' branding: icon: 'box' color: 'orange'
カテゴリは2つ選べるが、ぶっちゃけ何が適切なのかよくわからない。
Publish this Action to the GitHub Marketplace
にチェックを入れてリリースタグつければ完成です。
終わり
リリースのハードルも低く驚くほど簡単にできるのでこれはコミュニティの発展が期待できそう。