less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

CloudFormationのスタックのdiffをGithubで参照できるようにする

続きです。 bluepixel.hatenablog.com

自前のGithub Actionを作っていきます。

ワークフローの内容

CloudFormationで作成したスタックを更新する際、変更セットというものを作る必要があります。

この変更セットには、どのリソースの何が変更されるのが、置換が必要な操作なのかなどの情報を含まれているのですが、CLIで確認するには使い勝手が悪く、また、マネジメントコンソール上では確認できますが、変更されるプロパティの詳細までは表示されません。

f:id:bluepixel:20200429221140p:plain

まずこれをGithub上でわかりやすく確認できるようにしたいです。

また、IaC(Infrastructure as Code)においてネックとなるレビュー問題を解決します。CloudFormationの場合は、テンプレートとなるjsonまたはymlファイルを目視で確認する、または実際に変更セットを作ってみて内容を確認するという作業が発生します。これをGithub Actionsでオートメーション化して、プルリクがオープンされたのをトリガーに自動で変更内容を貼り付けるようにしたいと思います。

完成系

f:id:bluepixel:20200429221642p:plain

先に完成のイメージを貼りました。 見た目はマネジメントコンソールをなるべく模すようにしています。

実装

最初にワークフローの流れを整理します。

  • チェックアウト
  • AWS認証
  • 変更セットの作成 (creat-change-set)
  • 作成された変更セットの取得(describe-change-set
  • diffの抽出・整形
  • 変更セットの削除(delete-change-set
  • diffをプルリクにコメント

赤字の部分が今回マーケットプレイスにリリースしたGithub Actionsになります。

github.com

なぜ赤字の部分だけかというと、それは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を選んでいます。

github.com

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&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</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)

整形部分にはいくつかハックを施しています。

見慣れているマネジメントコンソールっぽく表示するのが見る側にとって負担がないので、まずそれを実現するために色をつけます。

f:id:bluepixel:20200429221642p:plainf:id:bluepixel:20200429221140p:plain

この色を付けるハックはStack Overflowで見つけた方法で、プレースホルダー画像を生成してくれるサイトを外部画像として参照するというものです。
背景色と文字色を同じにして適当なテキストを設定すればぽく見えます。

次に物理リソースIDを消します。長くて表が見づらくなるしそんなに需要はないと判断したためです。

代わりに、変更されるプロパティを追加します。
マネジメントコンソールには表示されないものなんですが、実はAPIでは取得できるのでリストします。本来これが一番重要な情報だったりすると思うんだが...

ちなみに["Target"]["Metadata"]["Target"]["Tag"]は無視してます。
これもあまり需要はないかと思ったので。

最後にこれらを表形式でまとめあげてファイルに書き出すんですが、マークダウンではなくHTMLとしたのには明確な理由があり、

最終的にプルリクにコメントとして投げる時に、JSONのボディに指定する値がmultiline stringだと大いに問題があります。
Githubの仕様としても、改行コードのエンコード・デコードがややこしく、::set-outputを通すとまた変換が行われたり、うまく改行を保ったままポストすることができませんでした。

github.community

最終的にたどり着いたのがHTMLで、さらにファイルの中身をcatxargs echoで参照する方法です(catだと失敗する)

run: |
    curl -X POST \
    -H "Authorization: token ${GITHUB_TOKEN}" \
    -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \
            ${URL}

テスト

テスト用のワークフローを作ります。

整形したデータはアーティファクトとして利用しています。

help.github.com

コメントのポストに必要なGithubトークンはすでに用意されているものを使います。

help.github.com

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}

リリース

できたものをマーケットプレイスにリリースします。 特に審査もなくサクッと出せます。

github.com

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つ選べるが、ぶっちゃけ何が適切なのかよくわからない。 f:id:bluepixel:20200429233800p:plain

Publish this Action to the GitHub Marketplace にチェックを入れてリリースタグつければ完成です。

終わり

リリースのハードルも低く驚くほど簡単にできるのでこれはコミュニティの発展が期待できそう。