一括請求で紐づいているAWSアカウントごとのコストをSlackで確認する

# 目的
請求情報をSlackから簡単に確認したいと思いました。

# 実現する機能
任意のSlackチャンネルで「/billing」を実行すると、**一括請求で紐付いている**アカウントごとの料金を取得する。

# 構成図
１．Slackの任意のチャンネルからスラッシュコマンドを実行
２．API Gatewayでリクエストを受け取り、リクエスト実行用Lambda呼び出し
３．Lambdaでスラッシュコマンドのトークンをチェック
　※AWS KMSを利用
４．S3の請求情報CSVから必要な情報を抽出しSNS呼び出し
５．SNSからSlack返却用のレスポンス実行用Lambdaを呼び出し
６．Slackに情報を通知

![image](https://qiita-image-store.s3.amazonaws.com/0/40136/4f9056a4-7aa2-1164-8abf-44417ce7dbc3.png)

SNSを使う理由は、~~Slash Commandsが3秒以内にレスポンスを返さないといけなかったからです。~~
連携させてみたかったからです。

# 一括請求（コンソリデーティッドビリング）とは？
複数のAWSアカウントを所有している場合支払いを１つのアカウントにまとめることができます。

[一括請求を使用した複数アカウントの料金の支払い](https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/consolidated-billing.html "一括請求を使用した複数アカウントの料金の支払い")
>一括請求機能を使って、1 つを支払いアカウントとして指定することで、組織内の複数のアマゾン ウェブ サービス (AWS) アカウントや複数の Amazon International Services Pvt. Ltd (AISPL) アカウントの支払いを統合できます。

# 請求レポートの設定
事前に請求レポートを特定のバケットに出力するよう設定をしておく必要があります。
設定方法については、以下を参考にしてください。

[請求レポートで使用量を確認する](http://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/detailed-billing-reports.html)

今回の対象ファイルは以下となります。
**(AWS account number)-aws-billing-csv-yyyy-mm.csv**

# SNSの設定

東京リージョンで新しいトピックを作成します。
今回TopicNameは「billing-lambda」としています。

<img width="405" alt="AWS_SNS.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/108f8787-13e0-4f6b-c409-99ed952c87f3.png">

# IAMの設定

## 暗号化キーの作成
Slackからリクエストする際のTokenを暗号化するためのKMSキーを作成します。
リージョンは**東京リージョン**で作成してください。
今回、キー管理者には自身のIAMユーザー、キーユーザーにはまだ何も設定しなくて大丈夫です。

<img width="895" alt="IAM_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/8bb15ee0-748f-dff0-0592-10c465ae2f2b.png">

<img width="1096" alt="IAM_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/3fb30755-5d8f-b1a9-7ec4-7d64abd1adc6.png">

## リクエスト実行用Lambdaのロール作成
API Gateway経由で実行されるリクエスト実行用Lambdaのロールを作成します。
以下の権限を持ったロールを作成します。

+ 一括請求用S3バケットから請求レポートを取得する
+ CloudWatch Logsにログを出力する 
+ レスポンス用Lambdaの呼び出しSNSにPublishを行う
+ 暗号化されたTokenを復号する

今回は、yamanaka_acceptBillingRequestというロールを作成し、以下ポリシーを設定しました。
※S3のResourceの最後にアスタリスク(*)が必要！

```json:acceptBillingRequestPolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1473241089000",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::<請求レポートが格納されているバケットのバケット名>/*"
            ]
        },
        {
            "Sid": "Stmt1473241213000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<作成した暗号化キーのARN>"
            ]
        },
        {
            "Sid": "Stmt1473241313000",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Sid": "Stmt1473241406000",
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": [
                "<レスポンス用Lambda呼び出しSNSのARN>"
            ]
        }
    ]
}
```

## レスポンス実行用Lambdaのロール作成
SNS経由で実行されるレスポンス実行用Lambdaのロールを作成します。
以下の権限を持ったロールを作成します。

+ CloudWatch Logsにログを出力する 

今回は、yamanaka_acceptBillingResponseというロールを作成し、以下ポリシーを設定しました。

```json:acceptBillingResponsePolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1473241745000",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

## KMSのキーユーザー設定

KMSの設定画面に戻りキーユーザーとしてリクエスト実行用のロールを設定します。

<img width="1103" alt="IAM_Management_Console.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/462b5718-68e6-249c-b831-945be69be0b4.png">

これでIAMでの設定は完了です。

# リクエスト実行用Lambdaファンクションの作成
リクエスト実行用のLambdaファンクションyamanaka_lambda_acceptBillingRequestを作成します。

各種設定は以下を選んでください。
　Runtime ： **Node.js 4.3**
　Role ： **リクエスト実行用のロール(yamanaka_acceptBillingRequest)**

また、今回は
　Memory ： **192MB**
　Timeout ： **3 sec**
としています。

## コード
コードは以下の通りです。
簡単に説明すると以下のようなことを行っています。

1.  SlackのパラメタからTokenを取得し、トークンが正しいかチェック
1.  該当月の請求レポートをS3バケットから取得
1.  請求レポートから各アカウントの請求金額を取得及び配列に格納
1.  格納した情報をメッセージとしてSNSを発行する

```js:yamanaka_lambda_acceptBillingRequest.js
/*jshint esversion: 6 */
/*jshint strict: true */
/* jshint node: true */
'use strict';

console.log('Loading function');
const AWS = require("aws-sdk");
const querystring = require('querystring');
const region = 'ap-northeast-1';
const now = new Date();

exports.handler = function(event, context, callback) {

	const generator  = (function *() {

        try {
			// FunctionArnからaccountIdを取得
			const accountId = context.invokedFunctionArn.match(/\d{3,}/)[0];
			const commandParams = querystring.parse(event["body-json"]);

			let targetMonth;
			if(!isNaN(parseInt(commandParams.text, 10))) {
				// 月が指定されている場合、引数を設定
				targetMonth = commandParams.text;
			} else {
				// 月が指定されていない場合、実行月を設定
				targetMonth = now.getUTCMonth() + 1;
			}

			// Slackのトークンをチェック
			yield tokenCheck(commandParams.token, generator);

			// S3からファイルを取得
			const billingCsvFile = yield getBillingFile(accountId, targetMonth, generator);

			// 必要な情報を取得
			const costList = pickUpCostList(billingCsvFile);
			// 通知元チャンネル名を格納
			costList[costList.length - 1].channelName = commandParams.channel_name;
			costList[costList.length - 1].month = targetMonth;

			// SNSパブリッシュ
			yield responseCall(accountId, costList, generator);

			callback(null,'Request is accepted');

		} catch (e) {
		    callback(e.message);
		}
	})();

	/* 処理開始 */
	generator.next();
};

// トークンチェック
function tokenCheck(token, generator) {
	const kms = new AWS.KMS({'region':region});
	const encryptedToken = '';
	const encryptedBuf = new Buffer(encryptedToken, 'base64');

	const kmsParams = {
		CiphertextBlob: encryptedBuf
	};

	kms.decrypt(kmsParams, function(err, data) {
	  if (err) {
		  console.log(err, err.stack);
		  generator.throw(new Error('Kms Decrypt Error'));
		  return;
	  } else {
		  if (data.Plaintext.toString() !== token) {
			  generator.throw(new Error('Slash Commands Token Error'));
		  }
		  console.log('token is OK');
		  generator.next();
	  }
	});
}

function getBillingFile(accountId, targetMonth, generator) {

	const s3 = new AWS.S3();
	const bucketName = '<請求レポートが格納されているバケットのバケット名>';
	const key = accountId + '-aws-billing-csv-' +
		now.getUTCFullYear() + '-' + ('0' + targetMonth).substr(-2) + '.csv';

	console.log('getting S3 file %s ..', key);

	const params = {
		Bucket: bucketName,
		Key: key
	};

	s3.getObject(params, function(err, data) {
		if (err) {
			console.log(err, err.stack);
			generator.throw(new Error('S3 Error'));
			return;
		}
		console.log('got S3 stream ..');
		generator.next(data.Body.toString());
	});
}

// Totalcostを二次元配列にとして返却
function pickUpCostList(csvFile){
    let estimateStatus = 'exact';
	const costList = [];
	// 一行ずつ配列としてtmpに格納
	const tmp = csvFile.split("\n");

	// 各行ごとにカンマで区切った文字列を要素とした二次元配列を生成
	for(let i = 0, listCount = 0, csvLine; i < tmp.length; ++i){
		csvLine = tmp[i].replace(/\"/g,"").split(',');
		if (csvLine[3] === 'AccountTotal') {
		    costList[listCount] = {};
		    // 必要な箇所だけ配列に格納
		    costList[listCount].accountId   = csvLine[2];
		    costList[listCount].accountName = csvLine[9];
		    costList[listCount].totalCost   = csvLine[csvLine.length - 1];
			listCount++;
		} else if (csvLine[3] === 'EstimatedDisclaimer') {
		    // 最終行にEstimatedDesclaimerが存在する場合、rough（概算）を設定
		    estimateStatus = 'rough';
		}
	}

	// accountNameで昇順にソート
	costList.sort(function(a,b) {
	   if(a.accountName<b.accountName) {
	       return -1;
	   }
	   if(a.accountName>b.accountName) {
	       return 1;
	   }
	   return 0;
	});

	costList[costList.length] = {};
	// costListの最後の要素に確定・概算のステータスを追加
	costList[costList.length - 1].estimateStatus = estimateStatus;

	return costList;
}

// SNSでLambdaを呼び出し
function responseCall(accountId, costList, generator) {
    const sns = new AWS.SNS({'region':region});
	const topicName = 'billing-lambda';

    const params = {
        Message: JSON.stringify(costList),
        TopicArn: 'arn:aws:sns:' + region + ':' + accountId + ':' + topicName
    };

    console.log('publishing SNS ..');
    sns.publish(params, function(err, data) {
        if (err) {
            console.log(err, err.stack);
			generator.throw(new Error('SNS Error'));
			return;
        } else {
            console.log('published SNS ..');
            console.log(data);
            generator.next();
        }
    });
}

```

# API Gatewayの設定

任意の名前でAPIを新しく作成します。
今回のリソースは以下のように設定しました。
　リソース ： **/slackcommand**
　メソッド ： **POST**



## メソッドのセットアップ
以下の通り選択してください。

統合タイプ ： **Lambda関数**
Lambdaリージョン ： **ap-northeast-1**
Lambda関数 ： **リクエスト実行用Lambda(yamanaka_lambda_acceptBillingRequest)**

<img width="1063" alt="API_Gateway.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/af2b1277-17ce-b2b3-451f-d0a9040127f7.png">

## 統合リクエストの設定

リクエスト本文のパススルー ： **テンプレートが定義されていない場合**
Content-Type ： **application/x-www-form-urlencoded**
テンプレートの生成 ： **メソッドリクエストのパススルー**

<img width="1067" alt="API_Gateway.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/f9876f09-d696-ded2-948e-851e688bde87.png">


※Content-Typeについては、以下に記載がありました。
　　[Slash Commands](https://api.slack.com/slash-commands)
>Typically, this data will be sent to your URL as a HTTP POST with a content-type header set as **<Font color="red">application/x-www-form-urlencodedred</font>**. If you've chosen to have your slash command's URL receive invocations as a GET request, no explicit content-type header will be set.

保存したら、新しいステージを作成し、デプロイしてください。
※今回はdevステージにデプロイ

<img width="1068" alt="API_Gateway.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/43e6eca4-1874-2efc-a94a-6b6ffa2606a5.png">

これでAPI Gatewayの作成は完了です！

# Slash Commandsの設定

## Slash Commandsとは？
Slash Commandsとは、任意のSlackチャンネルで``/commands``と入力することでカスタムコマンドを実行できる機能です。

注意として、Slash Commandsは実行後、3秒以内にレスポンスを返さないとタイムアウトしてしまいます。

## 設定手順

1. Slackにログインした状態でhttps://.slack.com/services/newにアクセス
2. 検索ボックスに「Slash Commands」と入力し選択する
3. 「Add Configuration」を選択
4. Choose a Commandに「/billing」を入力し、「Add Slash Command Integration」を選択する
5. 以下の通り設定

　　Command ： **/billing**
　　URL ： **API GatewayのURLを設定**
　　Method ： **POST**

　　**※Tokenは後ほど使用するのでメモしておく！**

<img width="857" alt="Slash_Commands___sks-2sp_Slack.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/8f8846ae-27fb-400f-5c4b-14da1b7a8561.png">



# トークンの暗号化
作成した暗号化キーでSlash CommandsのTokenを暗号化します。

ローカルのPCで以下コマンドを実行し、Tokenの暗号化を実行します。
（AWS CLIの環境は整っているものとします。）

```$ aws kms encrypt --key-id alias/<暗号化キーのalias> --plaintext="<Slash CommandsのToken>"```

実行すると以下が出力されるので、CiphertextBlobの値をメモしてください。

```json:
{    "KeyId": "arn:aws:kms:ap-northeast-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
     "CiphertextBlob": "BASE64-encoded-string"}
```

# 暗号化済みTokenの設定
リクエスト実行用Lambdaファンクション内の以下の箇所を修正します。

```js:acceptBillingRequest.js(修正前)
const encryptedToken = '';
```

```js:acceptBillingRequest.js(修正後)
const encryptedToken = '<メモしたCiphertextBlobの値>';
```

# Slackのincoming webhookの設定
以下の記事を参考に設定し、Webhook URLを控えておいてください。

[Slackにincoming webhook経由でpythonからメッセージをPOSTする](http://qiita.com/satoshi03/items/14495bf431b1932cb90b)


# レスポンス実行用Lambdaファンクションの作成
レスポンス実行用のLambdaファンクションyamanaka_lambda_acceptBillingResponseを作成します。

各種設定は以下を選んでください。
　Runtime ： **Node.js 4.3**
　Role ： **レスポンス実行用のロール(yamanaka_acceptBillingResponse)**

また、今回は
　Memory ： **256MB**
　Timeout ： **3 sec**
としています。

※このLambdaファンクションにはslack-nodeモジュールを使用しているため、Lambdaのjsファイルと一緒に以下slack-nodeモジュールもzip化しアップロードしてください。
[slack-node](https://www.npmjs.com/package/slack-node)

## コード
コードは以下のとおりです。
こっちも簡単に説明すると以下のようなことを行っています。

1.  SNSから各アカウントの請求金額を取得
1.  通知先のSlackチャンネルを設定
1.  通知するメッセージを作成
1.  Slack通知

webhookUriにはメモしておいたWebhook URLを入力してください。


```js:yamanaka_lambda_acceptBillingResponse.js
/*jshint esversion: 6 */
/*jshint strict: true */
/* jshint node: true */
'use strict';

console.log('Loading function');

exports.handler = function(event, context, callback) {
    try {
        const billingInfo = JSON.parse(event.Records[0].Sns.Message);
        // チャンネル名を設定
        const channelName = billingInfo[billingInfo.length - 1].channelName;

        // Slackで通知するメッセージを作成
        const message = createMessage(billingInfo);

        // Slack通知
        postMessage(channelName, message);

        callback(null, 'success');

    } catch (e) {
        callback(e.message);
    }
};

// Slackで通知するメッセージを作成
function createMessage(billingInfo) {

    // 見積もりステータスを設定
    const estimateStatus = billingInfo[billingInfo.length - 1].estimateStatus;
    // 対象月を設定
    const month = billingInfo[billingInfo.length - 1].month;

    const message = {
        text : '',
        messageAttachments : []
    };

    // 金額が確定か概算か
    if (estimateStatus === 'rough') {
        message.text = month + '月分 概算';
    } else if(estimateStatus === 'exact') {
        message.text = month + '月分 確定';
    }

    for (let i = 0, max = billingInfo.length - 1, account; i < max; i++) {

        if (billingInfo[i].accountName) {
            // accountNameが存在する場合はaccountNameを使用
            account = billingInfo[i].accountName;
        } else {
            // accountNameが存在しない場合はaccountIdを使用
            account = billingInfo[i].accountId;
        }

        // 小数点第3位で四捨五入
        const totalCost = Math.round(billingInfo[i].totalCost * 100) / 100;

        message.messageAttachments[i] = {
            color : '#36a64f',
            title : account,
            text  : '$ ' + totalCost
        };
    }

    return message;
}

// Slack通知
function postMessage(channel, message) {
    const Slack = require('slack-node');
    const slack = new Slack();
    // webhook用URI
    const webhookUri = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxx';
    slack.setWebhook(webhookUri);

    const slackParams = {
        channel : channel,
        username : "billingmaster",
        text : message.text,
        icon_emoji : ":aws:",
        attachments : message.messageAttachments
    };

    console.log('Send Message ..');

    slack.webhook(slackParams, function(err, response) {
        if (err) {
            console.log(err, err.stack);
            throw new Error('Could not send message');
        }
        console.log('Sent Message..');
        console.log(response);
    });
}
```

# 試してみる

一通りの準備が終わったので、実際に試して見ます。

任意Slackチャンネル内で```/billing```と入力し実行します。

![スクリーンショット_090916_102041_AM.jpg](https://qiita-image-store.s3.amazonaws.com/0/40136/eaff4b81-b953-a3f9-993f-83ff9cb6781a.jpeg)

実行後しばらく経つと、各アカウントごとに当月分の金額が出力されました。
今月はまだ金額が確定していないため**概算**と表示されています。

<img width="845" alt="Slack 2.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/f8cf34b9-bf68-8a17-1b95-67e405dfed07.png">

次は、引数を付けて実行してみます。
```/billing [month]```という形で指定月の情報を見ることができます。
```/billing 8```を実行して8月分の金額を確認してみます。

<img width="960" alt="Slack 3.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/b636023d-12b4-52d0-513f-18f0f14cde9d.png">

ちゃんと8月分の確定金額が表示されました。

<img width="783" alt="Slack 4.png" src="https://qiita-image-store.s3.amazonaws.com/0/40136/008b85b4-a740-ed00-c83c-b0f7a81eb9dd.png">

念のため8月分の請求情報をAWSのマネジメントコンソールから表示させてみると

![スクリーンショット_090916_101923_AM.jpg](https://qiita-image-store.s3.amazonaws.com/0/40136/8c1aca22-74b7-0a8b-0356-5937d31b6324.jpeg)

金額がちゃんと対応していることが確認できました！

# 備考
S3のトリガーイベントを利用することで、金額が確定した際にだけSlackの通知も行えるなーとは思ったのですが、確定したらAWSからPDFがくるしいいかなと思いやめました。
また、現在のコードは直近の金額確認しか意識していないため、コマンド実行時と同じ年の情報しかもってこれません。
そこらへんは使っていく中で不便に思ったら適宜修正する予定です。

引数チェック等もやってないので、必要であれば追加してください。

# 参考にさせていただきました
非常に参考にさせていただいたサイトです。ありがとうございます！

[SlackのSlash commandの処理をAWS Lambdaで実装](http://qiita.com/komeda-shinji/items/bd7bb3c2680e20218023)

[aws-billing](https://github.com/segmentio/aws-billing/blob/master/index.js)

# 以上
予想以上に長くなってしまいました。おつかれさまでした！