OJT中でも作成できたEC2を操作するAlexaカスタムスキル


## 概要
OJT中にAlexaを使って何か業務改善できないかな？と考えた末に作成したカスタムスキル「EC2の操作スキル」についてのレポート
ちなみにAlexaの知識は０からQiitaとか読んで勉強しました。結構簡単。

## 説明することしないこと
*すること*
*　カスタムスキルの説明
*　ソースの簡単な説明

*しないこと*
*　Alexaスキルの細かい説明（インテントとは何かとかセッションとは何かとか）


## 背景
Qiitaの記事を一通り見てカスタムスキルは作れるようになったぞ！
じゃあせっかくだから業務で使えるカスタムスキル作ってチームで使いたいな。というのがきっかけでした。
私が所属しているプロジェクトでAWSを使用していて、VPC内のアプリサーバーへは踏み台サーバーからSSHで接続して操作していたけど、これがまあまあ手間だったのでそこに役立ててみようかなと実践したのが今回作成したカスタムスキルです。

チームの運用として踏み台サーバーは常時起動ではなく、随時起動・停止をしてSlackでチームに周知する必要があったり、毎回AWSのコンソールからログインしてEC2開いて起動して待ってIPコピーしてSSHで繋いで…っていうのだけでも手間なのに開発環境とproduction環境が別アカウントなので、それぞれの踏み台サーバーにつなぎたいときはシークレットウィンドウで２回同じことする必要がありました。

AlexaのカスタムスキルはLambdaが公式の推奨だったりAWSリソースへの操作はSDK使えば簡単だろうなとあたりが付いていたのでサクッと作成して見ました。


## 作成したスキル
EC2の起動・停止・IPアドレスの取得・状態の取得とSlackへの通知を行う

「developでサーバー起動して」→ develop環境のEC2起動＆Slackへ「develop環境のサーバーが起動されました」の通知
「developでサーバーのIP教えて」→ 踏み台のパブリックIPを取得してSlackへ流す

背景として別環境のEC2をそれぞれ操作したかったので、スロットで起動するEC2を分けるとかはせずにカスタムスキルを環境ごとに作成しました。


## 構成図
矢印についた数字が処理の実行順になっており、LambdaでEC2の操作とSlackへの投稿を行います

![図1.png](https://qiita-image-store.s3.amazonaws.com/0/257378/ba1ee7c8-6eec-23d8-fe52-dba40a035708.png)





## 実装

Alexaのスキル側の設定

```
{
    "interactionModel": {
        "languageModel": {
            "invocationName": "デベロップ",
            "intents": [
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": [
                        "キャンセル"
                    ]
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": [
                        "ヘルプ"
                    ]
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": [
                        "やめた"
                    ]
                },
                {
                    "name": "startECC",
                    "slots": [],
                    "samples": [
                        "開始して",
                        "開始",
                        "起動して",
                        "起動",
                        "踏み台起動して",
                        "踏み台開始して"
                    ]
                },
                {
                    "name": "stopECC",
                    "slots": [],
                    "samples": [
                        "踏み台を停止して",
                        "踏み台終了して",
                        "踏み台停止して"
                    ]
                },
                {
                    "name": "describeECC",
                    "slots": [],
                    "samples": [
                        "IPアドレスを教えて",
                        "IPアドレス教えて",
                        "IP教えて",
                        "踏み台のIP流して",
                        "踏み台のIP教えて",
                        "踏み台のIPアドレス教えて"
                    ]
                },
                {
                    "name": "statusECC",
                    "slots": [],
                    "samples": [
                        "踏み台のステータス教えて",
                        "踏み台のステータスは",
                        "ステータスは",
                        "踏み台の状態教えて",
                        "状態教えて"
                    ]
                }
            ],
            "types": []
        }
    }
}
```

* 環境ごとに呼び出したいので、スキルの呼び出し名（invocationName）はデベロップにしました
    * 同じ内容で呼び出し名”本番”のカスタムスキルも作成しています
* インテントのCancelIntentとかHelpIntentとかは必須なのでとりあえずデフォルトで書いています
* やりたい操作ごとにカスタムインテントを作成していて、samplesにはそのインテントが呼ばれる発話例を設定します
* インテントごとの操作は以下の通り
    * startECC　→　EC2の起動
    * stopECC　→　EC2の停止
    * describeECC　→　EC2のパブリックIPをSlackに流す
    * statusECC　→　EC2の状態を教えてくれる（起動or停止）




---




Lambdaの実装

```Javascript:index.js
'use strict';
const Alexa = require('alexa-sdk');
const APP_ID = '作成したカスタムスキルIDを書く';

const HELP_MESSAGE = 'develop環境の踏み台に対して操作ができます';
const STOP_MESSAGE = 'Goodbye!!';

console.log('Loading function');

const INSTANCE_ID = '対象のインスタンスIDを書く'; 

const https = require('https');
const url = require('url');
const slack_url = 'https://hooks.slack.com/services/hogehoge';

const slack_req_opts = url.parse(slack_url);
slack_req_opts.method = 'POST';
slack_req_opts.headers = {'Content-Type': 'application/json'};
                
var AWS = require('aws-sdk'); 
AWS.config.region = 'リージョンを書く';
var ec2 = new AWS.EC2();
var params = {
    InstanceIds: [
    INSTANCE_ID
    ]
};

function ec2Start(self){
    ec2.startInstances(params, function(err, data) {
        var msg;
        if (!!err) {
            console.log(err, err.stack);
            msg = "エラーが発生しました";
        } else {
            msg = "developの踏み台を起動しました";
            var writeMessage = {
                link_names : true, 
                text: "@channel "+msg,
                channel: "#random"
            };
        
            var req = https.request(slack_req_opts, function (res) {
                if (res.statusCode === 200) {
                    console.log("ok");
                } else {
                    console.log("error");
                }
            });
            req.write(JSON.stringify(writeMessage));
            req.end();
        }
        self.response.speak(msg);
        self.emit(':responseReady');
    });
}

function ec2describe(self){
    ec2.describeInstances(params, function (err, data) {
        var msg;
        if (err){
            console.log(err, err.stack); 
        }else{
            if(data.Reservations[0].Instances[0].PublicIpAddress == null){
                var reprompt = "どうしますか";
                msg = "踏み台が起動していません。起動するなら踏み台を起動してと言ってください";
                self.response.speak(msg).listen(msg + ' ' + reprompt);
            }else{
                var str = "develop：IPv4 パブリック IP " + data.Reservations[0].Instances[0].PublicIpAddress;
                msg = "スラックにIPアドレスを流しました";
                
                var writeMessage = {
                    link_names : true, 
                    text: str,
                    channel: "#random"
                };
                var req = https.request(slack_req_opts, function (res) {
                    if (res.statusCode === 200) {
                        console.log("ok");
                    } else {
                        console.log("error");
                    }
                });
                req.write(JSON.stringify(writeMessage));
                req.end();
            }
        }
        self.response.speak(msg);
        self.emit(':responseReady');
    }); 
}


function ec2stop(self){
    ...
}

function ec2status(self){
    ...
}

const handlers = {
    'LaunchRequest': function () {
        const msg = "何をしますか？developの踏み台に対して操作ができます";
        const reprompt = "どうしますか";
        this.response.speak(msg);
        this.response.speak(msg).listen(msg + ' ' + reprompt);
        this.emit(':responseReady');
    },
    'AMAZON.HelpIntent': function () {
        const speechOutput = HELP_MESSAGE;
        const reprompt = "何か操作しますか";

        this.response.speak(speechOutput).listen(reprompt);
        this.emit(':responseReady');
    },
    'AMAZON.CancelIntent': function () {
        this.response.speak(STOP_MESSAGE);
        this.emit(':responseReady');
    },
    'AMAZON.StopIntent': function () {
        this.response.speak(STOP_MESSAGE);
        this.emit(':responseReady');
    },
    'startECC':function(){
        ec2Start(this);
    },
    'stopECC':function(){
        ec2Stop(this);
    },
    'describeECC':function(){
        ec2describe(this);
    },
    'statusECC':function(){
        ec2status(this);
    }
};

exports.handler = function (event, context, callback) {
    const alexa = Alexa.handler(event, context, callback);
    alexa.appId = APP_ID;
    alexa.registerHandlers(handlers);
    alexa.execute();
};

```

* INSTANCE_IDとAWS.config.region、APP_IDには適宜値を入れてください
    * 今回は踏み台サーバーだけを起動したかったので、インスタンスIDを一意に指定して実行していますが、スロットで起動したいサーバー名を受け取ってインスタンスIDをセットすることで、複数のサーバー操作ができると思います
* describeECCでは、踏み台が起動しているか停止してるかをパブリックIPが取得できるか否かで判断しています。（もっとスマートなやり方はありそう）
    * 起動していないなら追加で踏み台への操作を受け付けたいので、.listenを追加しています。これでセッションが終了せずユーザーからの再入力を求める状態になります
    * この分岐をfunction内で行いたかったので、thisを渡してselfとして実行しています
* Slackへの投稿部分はincomingWebHookを利用してPOSTしています
    * 参考：https://qiita.com/ik-fib/items/b4a502d173a22b3947a0


実装としては以上となります。あとはLambdaにEC2にアクセスするロールつけるぐらいで動くと思います。
やったことを記事にするのって難しい...