CodePipelineのステータスをSlackへ通知

## 環境

- ソースコードは GitHub で管理
- ビルドは CodeBuild で実行し、dockerコンテナのイメージをECRに登録
- デプロイ先は EC2インスタンスクラスタの ECS 
- CodePipelineで特定のブランチへのコミットをトリガーに、テスト環境へのビルド・デプロイを実行

## 課題

- いつビルド・デプロイが開始されたのか？
- いつデプロイが完了したのか？
- CodePipelineのマネージドコンソールを見ないとわからない

## 目的

- 開発メンバの誰もがテスト環境の構築状況を把握してもらいたい（デプロイ完了した？というのを無くす）
- ビルドまたはデプロイで失敗した場合に放置されないようにしたい

## 解決方法

- ビルド・デプロイの開始と完了（成功・失敗）を通知
- 通知先はチーム内のコミュニケーションツール Slack

## 構成

![Untitled Diagram.png](https://qiita-image-store.s3.amazonaws.com/0/297616/f16dea4a-d517-cf9e-3f44-d930e30140ec.png)

1. CodePipelineのステータスをCloudWatchで監視
2. Statusの変更があった場合に、Lambdaを起動
3. Lambdaではステータス結果を、Slackへ通知

## 通知内容

### ステータス
- CloudWatchでは、パイプラインのステータス、ステージのステータス、アクションのステータスの監視が可能
- パイプラインのステータスは、パイプラインの実行単位
- ステージのステータスは、コード取得→ビルド→デプロイでパイプラインを組んであるなら、コード取得、ビルド、デプロイ毎の単位
- アクションのステータスは、例えばビルド内のそれぞれのアクション単位

![codepipeline_01.png](https://qiita-image-store.s3.amazonaws.com/0/297616/1321e07e-6d83-bef4-31fd-1f2156fc3d49.png)

- 今回はビルド・デプロイがいつ開始され完了されたかを知りたいので、パイプライン単位のステータスを通知

### マネージドコンソールのURL

- 詳細な状況をマネージドコンソールで確認できるようにするため、通知内にマネージドコンソールへのリンクをはる
- しかし、CodePipelineでは実行単位の結果のみが表示される画面が存在しない
- 実行単位の結果をマネージドコンソールから確認した場合は、View pipeline history のページがら参照可能

![codepipeline_02.png](https://qiita-image-store.s3.amazonaws.com/0/297616/a10b0a86-a952-1d8f-ad3b-e80c577c0e74.png)

- 実行結果への直リンクがないため、Slackへの通知には、CodePipelineのプロジェクト単位のページへのリンクと、実行IDも通知に含め、各自Historyから詳細結果を参照できるようにする

## Slack

**Incoming WebHooks**

- Post to Channel を入力し、`Add Incoming WebHooks integration` をクリック
  - `Webhook URL`: 動的に振られる。これをLambda側で呼び出す。
  - `Post to Channel`: Lambda側でChannelは指定するので、特に気にしなくてもいい
  - `Customize Name`: Slackに表示される名前（適当に）
  - `Customize Icon`: Slackに表示されるアイコン（適当に）

## Lambda

**関数の作成**

- 設計図: cloudwatch-alarm-to-slack-python3

**基本的な情報**

- 名前: （任意）
- ロール: テンプレートから新しいロールを作成
- ロール名: （任意）
- ポリシーテンプレート: `KMSの復号化アクセス権限` （defaultのまま）

※Lambda作成後、関数の中でCodePipelineの情報を読み取るので、作成したロールに `AWSCodePipelineReadOnlyAccess` ポリシーを追加

**SNSトリガー**

- 削除

**Lambda関数のコード**

~~~lambda_function.py
import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']

HOOK_URL = "https://" + boto3.client('kms').decrypt(
    CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')

CODEPIPELINE_URL = "https://{0}.console.aws.amazon.com/codepipeline/home?region={0}#/view/{1}"
GITHUB_URL = "https://github.com/{0}/{1}/tree/{2}"
CODEBUILD_URL = "https://{0}.console.aws.amazon.com/codebuild/home?region={0}#/projects/{1}/view"
ECS_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/services/{2}/details"

SLACK＿MESSAGE_TEXT = '''\
*{0}* `{1}` {3} <{2}|CodePipeline>
```execution_id: {4}```
{5}
'''

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

# ステータスアイコン
STATE_ICONS = {
    'STARTED': ':seedling:',
    'FAILED': ':sweat_drops:',
    'SUCCEEDED': ':rainbow::rainbow::rainbow:'
}

def lambda_handler(event, context):
    logger.debug("Event: " + str(event))

    region = event["region"]
    pipeline = event["detail"]["pipeline"]
    version = event["detail"]["version"]
    execution_id = event["detail"]["execution-id"]
    state = event["detail"]["state"]
    url = CODEPIPELINE_URL.format(region, pipeline)
    # 開始時はCodePipelineの詳細情報を追加
    detail = pipeline_details(pipeline, version, region) if state == 'STARTED' else ''
    
    # 通知内容
    slack_message = {
        'channel': SLACK_CHANNEL,
        'text': SLACK＿MESSAGE_TEXT.format(
            pipeline, state, url, STATE_ICONS.get(state, ''),
            execution_id, detail),
        'parse': 'none'
    }

    # 通知
    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

# CodePipelineの詳細を取得
def pipeline_details(name, version, region):
    
    codepipeline = boto3.client('codepipeline').get_pipeline(
        name = name, version = int(version))
    logger.debug("get-pipeline: " + str(codepipeline))
    stages = []
    for stage in codepipeline['pipeline']['stages']:
        actions = []
        for action in stage["actions"]:
            provider = action["actionTypeId"]["provider"]
            if provider == 'GitHub':
                action_url = GITHUB_URL.format(
                    action["configuration"]["Owner"],
                    action["configuration"]["Repo"],
                    action["configuration"]["Branch"])
                actions.append(":octocat: <{0}|{1}>".format(
                    action_url, provider))
            elif provider == 'CodeBuild':
                action_url = CODEBUILD_URL.format(
                    region,
                    action["configuration"]["ProjectName"])
                actions.append(":codebuild: <{0}|{1}>".format(
                    action_url, provider))
            elif provider == 'ECS':
                action_url = ECS_URL.format(
                    region,
                    action["configuration"]["ClusterName"],
                    action["configuration"]["ServiceName"])
                actions.append(":ecs: <{0}|{1}>".format(
                    action_url, provider))
            else:
                actions.append(provider)
        stages.append("_{0}_ ( {1} )".format(stage["name"], ' | '.join(actions)))

    return ' => '.join(stages)
~~~

**環境変数**

- kmsEncryptedHookUrl: `Webhook URL` を入力
- slackChannel: `#your-channel`（通知したいチャネル）

**暗号化の設定**

- `伝送中の暗号化のためのヘルパーの有効化` をチェック
- `伝搬中に暗号化するKMSキー` 暗号化キーのarnまたはエイリアスを指定（未作成の場合は、IAM > 暗号化キー から作成）
- 保管時に暗号化する KMS キー: (デフォルト) aws/lambda

※キー設定後、環境変数の kmsEncryptedHookUrl の暗号化をクリック

## CloudWatch

**ルールの作成**

- イベントソース
    - サービス名: CodePipeline
    - イベントタイプ: CodePipeline Pipeline Execution State Change
    - 特定の条件: `CANCELED`, `FAILED`, `RESUMED`, `STARTED`, `SUCCEEDED`

- ターゲット
    - Lambda関数: （作成したLambdaの名前）

## 実行結果

**開始時**
![codepipelne2slack_started.png](https://qiita-image-store.s3.amazonaws.com/0/297616/d4a7601f-2f38-402e-13f2-da44151ee41f.png)

**失敗時**
![codepipelne2slack_failed.png](https://qiita-image-store.s3.amazonaws.com/0/297616/20fa2322-a7eb-c319-aac2-87365732e45b.png)

**成功時**
![codepipelne2slack_succeeded.png](https://qiita-image-store.s3.amazonaws.com/0/297616/ee07482a-5d96-740e-00f7-167508f6aa84.png)

## まとめ

- 絵文字入れているのは視覚的にわかりやすくなることを期待して
- コード上にも記載してるが、ソースの取得もとは GitHub 前提で書いているので、CodeCommitやS3などが元になる場合は、適宜変更を加える
- CodePipeline から取得する情報をステージ単位、アクション単位にしたい場合は、CloudWatchのイベントタイプで、`CodePipeline Stage Execution State Change` または `CodePipeline Action Execution State Change` を選択（サンプルイベントが表示されるのでそれを参考に作成）
- CodeBuild等のステータス監視も同様に「CloudWatchでステータス監視→Lambda→Slack」の流れで可能

## おまけ

### CodeBuild to Slacck

- CloudWatchではサービス「CodeBuild」イベントタイプ「CodeBuild Build State Change」で監視

**Lambda関数のコード**

~~~lambda_function.py
import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']

HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')

CODEBUILD_URL = "https://{0}.console.aws.amazon.com/codebuild/home?{0}#/builds/{1}/view/new"

SLACK＿MESSAGE_TEXT = '''\
*{0}* `{1}` <{2}|CodeBuild>
'''

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info("Event: " + str(event))

    region = event["region"]
    build_id = event["detail"]["build-id"].split('/')[1]
    url = CODEBUILD_URL.format(region, build_id)
    project = event["detail"]["project-name"]
    state = event["detail"]["build-status"]

    # 通知内容
    slack_message = {
        'channel': SLACK_CHANNEL,
        'text': SLACK＿MESSAGE_TEXT.format(project, state, url),
        'parse': 'none'
    }

    # 通知
    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)
~~~

**実行結果**
![codebuild2.png](https://qiita-image-store.s3.amazonaws.com/0/297616/304c2dc4-ca63-88e6-a959-965b87de875d.png)

### ECS to slack

- CloudWatchではサービス「ECS」イベントタイプ「状態変更」（タスクのステータス`ECS Task State Change`のみ）で監視

**Lambda関数のコード**

~~~lambda_function.py
import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']

HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')

TASK_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/tasks/{2}/details"
TASK_DEFINITION_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/taskDefinitions/{1}"
CONTAINER_INSTANCE_URL = "https://{0}.console.aws.amazon.com/ecs/home?region={0}#/clusters/{1}/containerInstances/{2}"

SLACK＿MESSAGE_TEXT = '''\
*{0}* `{1}` {2} <{3}|Task> ( _<{4}|{5}>_ )
'''

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

STATE_ICONS = {
    'RUNNING': ':runner::runner:',
    'STOPPED': ':skull_and_crossbones:'
}

def lambda_handler(event, context):
    logger.debug("HOOK_URL: " + str(HOOK_URL))
    logger.debug("Event: " + str(event))

    region = event["region"]
    cluster_id = event["detail"]["clusterArn"].split('/')[1]
    container_instance_id = event["detail"]["containerInstanceArn"].split('/')[1]
    task_id = event["detail"]["taskArn"].split('/')[1]
    task_definition_id = event["detail"]["taskDefinitionArn"].split('/')[1]
    desired_status = event["detail"]["desiredStatus"]
    last_status = event["detail"]["lastStatus"]
    
    task_url = TASK_URL.format(region, cluster_id, task_id)
    task_definition_url = TASK_DEFINITION_URL.format(region, task_definition_id.replace(':', '/'))
    container_instance_url = CONTAINER_INSTANCE_URL.format(region, cluster_id, container_instance_id)
    
    # 期待するステータスになる過程のステータスは通知しない
    if desired_status != last_status:
        return
    
    # 通知内容
    slack_message = {
        'channel': SLACK_CHANNEL,
        'text': SLACK＿MESSAGE_TEXT.format(
            cluster_id,
            last_status,
            STATE_ICONS.get(last_status, ''),
            task_url,
            task_definition_url,
            task_definition_id)
    }

    # 通知
    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)
~~~

**実行結果**
![ecs.png](https://qiita-image-store.s3.amazonaws.com/0/297616/5661675d-4ad5-26b4-dc67-0ffa14c0ed05.png)