シナリオ
- Slack → API Gateway → Lambda
Slack 側では Custom Integrations の Slash Commands を使用します。
AWS 側は blueprint にある slack-echo-command-python を使ってコマンドを実行する Lambda 関数を作成します。
Lambda 関数の処理では 3 秒以内にSlack 側へ応答を返す必要があります。
時間がかかる処理を行うために、Lambda で受信したコマンドは、SNS を経由して別の Lambda 関数をイベント起動します。
- Lambda → SNS → Lambda → Slack
SNS イベントから起動される Lambda 関数では STS を利用して、Slash Command を発行したユーザのロールを引き受けてAPI コマンドを実行します。
- 利用したblueprint
- slack-echo-command-python
Slack 側は Custom Integrations の Slash Commands を使用します。 - cloudwatch-alarm-to-slack-python
Incoming Webhooks でなくresponse_url
を使って応答を返すように修正しました。
- slack-echo-command-python
Slack → API Gateway → Lambda
Lambda 関数の作成
blueprint として slack-echo-command-python を選択して、Lambda 関数の登録を行います。
ロールを作成します。内容はひとまずデフォルトのままでかまいません。
エンドポイント設定では Method を POST に、セキュリティ設定は Open で作成します。
最初は関数の本体はbuleprint のままでかまいません。
Slash Command App の登録
blueprint の先頭にコメントで設定手順が書かれていますので、これにそって進めます。
This function handles a Slack slash command and echoes the details back to the user.
Follow these steps to configure the slash command in Slack:
-
Navigate to https://.slack.com/services/new
-
Search for and select "Slash Commands".
-
Enter a name for your command and click "Add Slash Command Integration".
-
Copy the token string from the integration settings and use it in the next section.
tokenの文字列はこのあとの設定で使いますので控えておいてください。 -
After you complete this blueprint, enter the provided API endpoint URL in the URL field.
KMSの設定
Follow these steps to encrypt your Slack token for use in this function:
-
Create a KMS key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html.
IAMのマネジメントコンソールから暗号化キーの設定を行います。 -
Encrypt the token using the AWS CLI.
$ aws kms encrypt --key-id alias/ --plaintext ""$ aws kms encrypt --key-id alias/slack-token --plaintext="XXXXXXXXXXXXXXXXXXXXXXXX"
{ "KeyId": "arn:aws:kms:ap-northeast-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "CiphertextBlob": "BASE64-encoded-string"}
-
Copy the base-64 encoded, encrypted key (CiphertextBlob) to the kmsEncyptedToken variable.
出力された CiphertextBlob の値を Lambda 関数の
ENCRYPTED_EXPECTED_TOKEN
変数の値として設定します。ENCRYPTED_EXPECTED_TOKEN = "BASE64-encoded-string" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)
-
Give your function's role permission for the kms:Decrypt action.
Example:{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "kms:Decrypt" ], "Resource": [ "<your KMS key ARN>" ] } ] }
この例を参考に先ほど作成したポリシーを修正します。
lambda_basic_execution
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*" }, { "Effect": "Allow", "Action": [ "kms:Decrypt" ], "Resource": [ "arn:aws:kms:us-east-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ] } ] }
エンドポイントの設定
Follow these steps to complete the configuration of your command API endpoint
-
When completing the blueprint configuration select "POST" for method and
"Open" for security on the Endpoint Configuration page.すでに、この内容で作成しているはずです。設定が合っていないときは変更してください。
-
After completing the function creation, open the newly created API in the
API Gateway console. -
Add a mapping template for the application/x-www-form-urlencoded content type with the following body: { "body": $input.json("$") }
API Gateway の Resource で、Integration Request の Body Mapping Templates を追加します。
-
Deploy the API to the prod stage.
修正後、Deply API で反映しておきます。
-
Update the URL for your Slack slash command with the invocation URL for the created API resource in the prod stage.
エンドポイントの設定ができましたので、Slack 側の Slash Commands の Integration Settings でエンドポイントのURLを設定しておきます。
最初のコード
この時点で、Lambda 関数は ENCRYPTED_EXPECTED_TOKEN の値を設定しただけで、blueprint のままになっているはずです。
lambda-botコード
import boto3
from base64 import b64decode
from urlparse import parse_qs
import logging
ENCRYPTED_EXPECTED_TOKEN = "<KMS出力のCiphertextBlobのRVALUEであるBASE64文字列">" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)
kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob = b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
req_body = event['body']
params = parse_qs(req_body)
token = params['token'][0]
if token != expected_token:
logger.error("Request token (%s) does not match exptected", token)
raise Exception("Invalid request token")
user = params['user_name'][0]
command = params['command'][0]
channel = params['channel_name'][0]
command_text = params['text'][0]
return "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text)
テスト
マネジメントコンソールからテストするときのテストイベントは次のようになります。
tokenの値は Slash Commands の設定で控えた値を指定してください。
{
"body":
"token=XXXXXXXXXXXXXXXXXXXXXXXX&command=/lambda&text=ec2&user_name=Steve&channel_name=test&response_url=https://hooks.slack.com/commands/1234/5678"
}
ここまでエラーなく動くようになったら、Slack からテストしてみます。
1回目はタイムアウトでエラーになるかもしれません。そのときはもう一度コマンドを発行してみてください。
応答が返ってくれば成功です。
blueprint のサンプルでは return の引数は文字列となっていますが、これでは応答の前後に二重引用符がついて表示されます。Slash Commands の解説にあるように、JSON 形式で返せば二重引用符はつきません。
return { "text": "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text) }
あとは自由にコードを足して、好みの振る舞いをするボットを作成しましょう。
Lambda → SNS → Lambda → Slack
blueprint にある cloudwatch-alarm-to-slack-python を利用して notify-to-lambda を作成します。
Incoming Webhooks は使用しないため、blueprint の先頭に書かれている手順は実行しなくてもかまいません。
Slash Commands の説明で response_url
を利用した応答の返しかたが記載されています。
この内容をもとに Incoming Webhooks でなく response_url
を使って応答を返すように修正します。
Delayed responses and multiple responses
If you want to provide additional command response messages, or if you're unable to immediately respond to a command within 3000 milliseconds, use the specificresponse_url
we send with our initial execution of your URL to respond to a command at your leisure. With this approach, you can respond to a user commands up to 5 times within 30 minutes of the user's invocation.
Sending HTTP requests to thisresponse_url
is easy. Just build a JSON POST body in the same format as used when responding to our command invocation request to your registered URL. It supports all of the same fields (response_type
,text
, andattachments
). Then, send that data as an HTTP POST with acontent-type
ofapplication/json
to the destination specified as theresponse_url
.
The only user-facing difference between immediate responses and delayed responses is that "in channel" delayed responses will not include the initial command sent by the user. To echo the command back to the channel, you'll still need to provide a response to Slack's original visit to your invocation URL.
SNS Topic の作成
SNS の受信イベントで Lamba 関数を起動するために、SNS Topic を作成し、Subscription を Protocol lambda で作成します。
Lambda関数の作成
blueprintのコードの修正
lambda-bot 側では、パラメータを JSON 形式で SNS メッセージとして送ります。
Publish - Amazon Simple Notification Service より引用 (太字部分は筆者による強調)
MessageStructure
SetMessageStructure
tojson
if you want to send a different message for each protocol. For example, using one publish action, you can send a short message to your SMS subscribers and a longer message to your email subscribers. If you setMessageStructure
tojson
, the value of theMessage
parameter must:
be a syntactically valid JSON object; and
contain at least a top-level JSON key of "default" with a value that is a string.
You can define other top-level keys that define the message you want to send to a specific transport protocol (e.g., "http").
For information about sending different messages for each protocol using the AWS Management Console, go to Create Different Messages for Each Protocol in theAmazon Simple Notification Service Getting Started Guide.
Valid value:json
Type: String
Required: No
このために関数の本体を次のように変更します。
lambda-botコード
def lambda_handler(event, context):
req_body = event['body']
params = parse_qs(req_body)
token = params['token'][0]
if token != expected_token:
logger.error("Request token (%s) does not match exptected", token)
raise Exception("Invalid request token")
user = params['user_name'][0]
command = params['command'][0]
channel = params['channel_name'][0]
if params.has_key('text'):
command_text = params['text'][0]
else:
command_text = ''
response_url = params['response_url'][0]
arg = command_text.split(' ')
sns = boto3.client('sns')
topic_arn = sns.create_topic(Name='sns-lambda')['TopicArn']
message={"user_name": user, "command": command, "channel": channel, "command_text": command_text, "response_url": response_url}
message=json.dumps(message)
message=json.dumps({'default': message, 'lambda': message})
response = sns.publish(
TopicArn=topic_arn,
Subject='/lambda',
MessageStructure='json',
Message=message
)
return { "text": "%s %s\nroger" % (command, command_text) }
受信側は blueprint のコードを次のように修正します。
HOOK_URL
のかわりに response_url
を使用し、JSON 形式で応答を返すようにしています。
notify-to-slackコード
def lambda_handler(event, context):
logger.info("Event: " + str(event))
message = event['Records'][0]['Sns']['Message']
try:
message = json.loads(message)
user_name = message['user_name']
command = message['command']
command_text = message['command_text']
response_url = message['response_url']
arg = command_text.split(' ')
# if response_type is not specified, act as the same as ephemeral
# ephemeral, response message will be visible only to the user
slack_message = {
'channel': '@%s' % user_name,
#'response_type': 'in_channel',
'response_type': 'ephemeral',
'isDelayedResponse': 'true',
'text': "response for: %s %s" % (command, command_text)
}
logger.info("Send message to %s %s", response_url, slack_message)
req = Request(response_url)
req.add_header('Content-Type', 'application/json')
response = urlopen(req, json.dumps(slack_message))
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)
Lambda関数のロールの修正
SNS トピックへの送信を許可します。
lambda_basic_execution
{
"Effect": "Allow",
"Action": [
"sns:*"
],
"Resource": [
"arn:aws:sns:us-east-1:XXXXXXXXXXXX:sns-lambda"
]
},
テスト
マネジメントコンソールからテストするときのテストイベントは次のようになります。
response_url
の値は、Slash Commands で送信されてきたものをログに残すなどして入手してください。
{
"Records": [
{
"Sns": {
"Message": "{\"command\": \"/lambda\", \"command_text\": \"ec2 console i-XXXXXXXX\", \"user_name\": \"Steve\", \"channel_name\": \"test\", \"response_url\": \"https://hooks.slack.com/commands/1234/5678\"}"
}
}
]
}
テストで問題がなければ、Lamba 関数で Event source にSNSトピックを指定し State を Enable にしておきます。
Slack からテストしてみます。
このような応答が得られれば大丈夫です。
もう少し実用的なコード
EC2インスタンスの一覧やコンソールの出力の取得ができるようにします。
またS3バケットの一覧も取得してみます。
notify-to-slack 側 slack_message = { … } の後につぎのコードを追加します。
if arg[0] == 'ec2':
if len(arg) == 3:
if arg[1] == 'console':
ec2 = boto3.resource('ec2')
instance = ec2.Instance(arg[2])
response = instance.console_output()
output = '\n'.join([x.rstrip() for x in response['Output'].split('\n')[-20:]])
slack_message['text'] = output
else:
ec2 = boto3.client('ec2')
response = ec2.describe_instances()
if response.has_key('Reservations') and len(response['Reservations']) >= 1:
status = [(lambda x: (x[u'InstanceId'], ', '.join([t[u'Value'] for t in x[u'Tags']]), x[u'State'][u'Name']))(i) for i in response[u'Reservations'][0][u'Instances']]
slack_message['text'] = '%s %s\n%s' % (command, command_text, '\n'.join(['%s: (%s) %s' % x for x in status]))
else:
slack_message['text'] = "%s %s\nno instance found" % (command, command_text)
elif arg[0] == 's3':
s3 = boto3.client('s3')
buckets = s3.list_buckets()
slack_message['text'] = "%s %s\n%s" % (command, command_text, ' '.join([x[u'Name'] for x in buckets[u'Buckets']]))
Lambda関数のロールの修正
EC2 インスタンス、S3 バケットの情報を参照できるようにします。
lambda_basic_execution
{
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"ec2:Get*"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Action": [
"s3:Get*",
"s3:List*"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Action": [
"cloudwatch:Get*"
],
"Resource": [
"*"
]
},
STSの利用
ボットを呼び出すユーザに応じて、コマンドを実行できる権限を変えることを考えます。
クロスアカウントアクセスのロール virginia-ec2-delegate を作成して、信頼関係で Lambda サービスを追加しました。
STS の設定についての理解が十分でないため、これで必要十分性なのかよくわかりませんが、動いているのでおそらく大丈夫だと思います。
ユーザ名をそのまま利用しているため、AWS の IAM ユーザ名と Slack のユーザ名は合わせておく必要があります。
ロールvirginia-ec2-delegate
"Principal": {
"Service": "lambda.amazonaws.com"
},
ポリシーec2-full-access
ロールassume-role
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"sts:AssumeRole"
],
"Effect": "Allow",
"Resource": "*"
}
]
}
Lambda関数のロールの修正
lambda_basic_execution
IAMの必要な参照ができるようにロールのポリシーを修正します。
{
"Effect": "Allow",
"Action": [
"iam:GetUser",
"iam:GetRole",
"iam:UserPolicy"
],
"Resource": [
"*"
]
}
notify-to-slackコード
iam = boto3.client('iam')
response = iam.get_user(UserName=user_name)
if response.has_key('User'):
uid = response['User']['Arn'].split(':')[4]
if uid and boto3.resource('iam').UserPolicy(user_name, 'ec2-full-access'):
response = iam.get_role(RoleName='virginia-ec2-delegate')
if response.has_key('Role'):
sts = boto3.client('sts')
assumedRoleObject = sts.assume_role(RoleArn="arn:aws:iam::%s:role/%s" % (uid, 'virginia-ec2-delegate'), RoleSessionName='session')
credentials = assumedRoleObject['Credentials']
ec2 = boto3.resource(
'ec2',
aws_access_key_id=credentials['AccessKeyId'], aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token = credentials['SessionToken'],
)
instance = ec2.Instance(arg[2])
state = 'unknown'
if arg[1] == 'start':
response = instance.start()
if response.has_key('StartingInstances'):
state = response['StartingInstances'][0]['CurrentState']['Name']
slack_message['text'] = '%s %s' % (arg[2], state)
elif arg[1] == 'stop':
response = instance.stop()
if response.has_key('StoppingInstances'):
state = response['StoppingInstances'][0]['CurrentState']['Name']
slack_message['text'] = '%s %s' % (arg[2], state)
テスト
Lambda 関数には EC2 インスタンスの起動/停止を行う権限は持たせていませんが、STS を利用してユーザ権限を引き受けて実行します。
サンプルコード
lambda-bot
import boto3
from base64 import b64decode
from urlparse import parse_qs
from datetime import datetime, timedelta
import json
import logging
ENCRYPTED_EXPECTED_TOKEN = "<KMS出力のCiphertextBlobのRVALUEであるBASE64文字列">" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)
kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob = b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
req_body = event['body']
params = parse_qs(req_body)
token = params['token'][0]
if token != expected_token:
logger.error("Request token (%s) does not match exptected", token)
raise Exception("Invalid request token")
user = params['user_name'][0]
command = params['command'][0]
channel = params['channel_name'][0]
if params.has_key('text'):
command_text = params['text'][0]
else:
command_text = ''
response_url = params['response_url'][0]
arg = command_text.split(' ')
if arg[0] in ['ec2', 's3']:
sns = boto3.client('sns')
topic_arn = sns.create_topic(Name='sns-lambda')['TopicArn']
message={"user_name": user, "command": command, "channel": channel, "command_text": command_text, "response_url": response_url}
message=json.dumps(message)
message=json.dumps({'default': message, 'lambda': message})
response = sns.publish(
TopicArn=topic_arn,
Subject='/lambda',
MessageStructure='json',
Message=message
)
return { "text": "%s %s\nroger" % (command, command_text) }
elif arg[0] == 'help':
return { "text": "ec2 [console|start|stop instance-id]\ns3 [usage bucket]" }
else:
return { "text": "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text) }
notify-to-slack
from __future__ import print_function
import boto3
import json
import logging
from base64 import b64decode
from urllib2 import Request, urlopen, URLError, HTTPError
from time import sleep
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def send_response(response_url, message):
req = Request(response_url)
req.add_header('Content-Type', 'application/json')
try:
response = urlopen(req, json.dumps(message))
response.read()
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)
def ec2_command(arg, user_name, response_url):
if len(arg) == 3:
if arg[1] == 'console':
ec2 = boto3.resource('ec2')
instance = ec2.Instance(arg[2])
response = instance.console_output()
output = '\n'.join([x.rstrip() for x in response['Output'].split('\n')[-20:]])
return output
elif arg[1] in ['start', 'stop']:
iam = boto3.client('iam')
response = iam.get_user(UserName=user_name)
if response.has_key('User'):
uid = response['User']['Arn'].split(':')[4]
if uid and boto3.resource('iam').UserPolicy(user_name, 'ec2-full-access'):
response = iam.get_role(RoleName='virginia-ec2-delegate')
if response.has_key('Role'):
sts = boto3.client('sts')
assumedRoleObject = sts.assume_role(RoleArn="arn:aws:iam::%s:role/%s" % (uid, 'virginia-ec2-delegate'), RoleSessionName='session')
credentials = assumedRoleObject['Credentials']
ec2 = boto3.resource(
'ec2',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token = credentials['SessionToken'],
)
instance = ec2.Instance(arg[2])
state = instance.state['Name']
if arg[1] == 'start':
if state == 'stopped':
response = instance.start()
if response.has_key('StartingInstances'):
state = response['StartingInstances'][0]['CurrentState']['Name']
if state == 'pending':
if response_url:
send_response(response_url, { 'text': 'start %s' % arg[2] })
ec2client = boto3.client('ec2')
for wait in range(0,20):
response = ec2client.describe_instances(InstanceIds=[arg[2]])
state = response[u'Reservations'][0][u'Instances'][0]['State']['Name']
if state == 'running': break
sleep(3)
return '%s become %s' % (arg[2], state)
else:
return '%s is already %s' % (arg[2], state)
elif arg[1] == 'stop':
if state == 'running':
response = instance.stop()
if response.has_key('StoppingInstances'):
state = response['StoppingInstances'][0]['CurrentState']['Name']
if state == 'stopping':
if response_url:
send_response(response_url, { 'text': 'stop %s' % arg[2] })
ec2client = boto3.client('ec2')
for wait in range(0,20):
response = ec2client.describe_instances(InstanceIds=[arg[2]])
state = response[u'Reservations'][0][u'Instances'][0]['State']['Name']
if state == 'stopped': break
sleep(3)
return '%s become %s' % (arg[2], state)
else:
return '%s is already %s' % (arg[2], state)
else:
return '%s: unknown' % arg[2]
else:
return '%s: user unknown' % user_name
else:
ec2 = boto3.client('ec2')
response = ec2.describe_instances()
if response.has_key('Reservations') and len(response['Reservations']) >= 1:
status = [(lambda x: (x[u'InstanceId'], ', '.join([t[u'Value'] for t in x[u'Tags']]), x[u'State'][u'Name']))(i) for i in response[u'Reservations'][0][u'Instances']]
return '\n'.join(['%s: (%s) %s' % x for x in status])
else:
return "no instance found"
def s3_command(arg, user_name, response_url):
if len(arg) == 3 and arg[1] == 'usage':
cloudwatch = boto3.client('cloudwatch')
now = datetime.utcnow()
response = cloudwatch.get_metric_statistics(
Namespace='AWS/S3',
MetricName='BucketSizeBytes',
Dimensions=[
{ 'Name': 'BucketName', 'Value': arg[2] },
{ 'Name': 'StorageType', 'Value': 'StandardStorage' }
],
StartTime=now - timedelta(days=1),
EndTime=now,
Period=86400,
Statistics=['Average'],
Unit='Bytes'
)
if response.has_key('Datapoints') and len(response[u'Datapoints']) >= 1:
return response[u'Datapoints'][0][u'Average']
else:
return "no metric found"
else:
s3 = boto3.client('s3')
buckets = s3.list_buckets()
return ' '.join([x[u'Name'] for x in buckets[u'Buckets']])
def lambda_handler(event, context):
message = event['Records'][0]['Sns']['Message']
logger.info("Event: " + str(message))
if True:
message = json.loads(message)
user_name = message['user_name']
command = message['command']
command_text = message['command_text']
response_url = message['response_url']
arg = command_text.split(' ')
# if response_type is not specified , act as the same as ephemeral
# ephemeral, response message will be visible only to the user
slack_message = {
'channel': '@%s' % user_name,
#'response_type': 'in_channel',
'response_type': 'ephemeral',
'isDelayedResponse': 'true',
'text': "%s %s" % (command, command_text)
}
if arg[0] == 'ec2':
slack_message['text'] = ec2_command(arg, user_name, response_url)
send_response(response_url, slack_message)
elif arg[0] == 's3':
slack_message['text'] = s3_command(arg, user_name, response_url)
send_response(response_url, slack_message)
else:
slack_message = { 'text': "command failed" }
send_response(response_url, slack_message)