LoginSignup
6
3

More than 3 years have passed since last update.

インタラクティブに terraform plan 実行してくれるslackbot

Last updated at Posted at 2019-06-16

はじめに

うちのチームでは、AWS/GCP合わせて、17プロジェクト程 terraformで管理しています。
日々terraformの差分の確認等を少しでも自動化すべくslackbotを活用した事例です。

自身の過去記事でterraform planを実行するslack botを作成しました。

https://qiita.com/andromeda/items/b07ae280270d4e0c4709
pythonでterraform planを実行してslackへ通知する

https://qiita.com/andromeda/items/d68a0c36667cc802987c
Pythonのslackbotライブラリでterraform plan実行してもらう

今回はそこから色々改良したので、その内容を書きたいと思います。

  • slack botの部分はpythonからGolangに書き換えました。
  • slack の Interactive messages 使ってterraform planする対象プロジェクトを選択出来る様にしました。
  • OSのコマンド(terraform実行とか)はGoからshell scriptファイルを実行する様にしてます。

大変お世話になったサイト
https://tech.mercari.com/entry/2017/05/23/095500

サマリ

SlackのInteractive Messageを使い、terraform planを実行してくれるBotです。

terraformで管理している全てのプロジェクトで terraform planを実行し結果をSlackへ通知する例です。

201906161705.gif

5分程で、以下の様に結果がSlackへ通知されます。

Slack - blnetwork 2019-06-16 16-30-34.png

実行結果のログはGCS(Google Cloud Storage)にアップロードし、Slack通知のプロジェクト名にリンクにしています。
リンクをクリックすると以下の様にブラウザで結果の確認が出来ます。
※GCSに保存する際に、 Content-Type:text/plain に指定するのがポイントです。
デフォルトのContent-Typeだと、リンククリックするとファイルがDLされてしまいます。

https:00e9e64bac0fefb142734409e1cf556c93cb4b2bd8.png

特定プロジェクトのみ terraform plan を実行し結果をSlackへ通知する例です。

201906161643.gif

同様に以下の様に結果がSlackへ通知されます。

Slack - blnetwork 2019-06-16 16-44-42.png

出来たコードやscriptはここにまとめています。
パート毎にサンプルコードのポイントを簡単に解説させて頂きます。

Slack EventをWatchして選択MenuをPOST

まずはSlackのEventをWatchするパートです。
特定のチャンネルで@terrabot宛にメンションが付くと、プロジェクトのMenuをユーザへ表示します。
SlackのAPI Clientには nlopes/slack を利用します。
以下の様に、基本的に MessageEvent を見張っておけばメッセージを拾えます。

main.go
func (s *SlackListener) ListenAndResponse() {
    rtm := s.client.NewRTM()

    // Start listening slack events
    go rtm.ManageConnection()

    // Handle slack events
    for msg := range rtm.IncomingEvents {
        switch ev := msg.Data.(type) {
        case *slack.MessageEvent:
            if err := s.ValidateMessageEvent(ev); err != nil {
                log.Printf("[ERROR] Failed to handle message: %s", err)
            }
        }
    }
}

次に MessageEvent を処理する関数です。
今回のサンプルは、以下をValidateしています。
* 特定のチャンネルIDからのメッセージか
* bot宛のメンションが付いたメッセージか(先頭文字のbotIDをValidate)
その他にも、「特定のユーザのみ」で制限をかけたり、「特定の文字列が含まれてたら」で
コマンド方式にしたり、など色々出来ると思います。
検証が終わったら、slack Attachment を使いチャンネルへPostします。
SlackへのPOSTはメソッドが用意されてます。

main.go
func (s *SlackListener) ValidateMessageEvent(ev *slack.MessageEvent) error {
    // Only response in specific channel. Ignore else.
    if ev.Channel != s.channelID {
        log.Printf("%s %s", ev.Channel, ev.Msg.Text)
        return nil
    }

    // Only response mention to bot. Ignore else.
    if !strings.HasPrefix(ev.Msg.Text, s.botID) {
        log.Printf("%s %s", ev.Channel, ev.Msg.Text)
        return nil
    }

    bytes, err := ioutil.ReadFile("menu.json")
    if err != nil {
        log.Fatal(err)
    }

    var attachment = slack.Attachment{}
    if err := json.Unmarshal(bytes, &attachment); err != nil {
        log.Fatal(err)
    }

    params := slack.MsgOptionAttachments(attachment)
    if _, _, err := s.client.PostMessage(ev.Channel, params); err != nil {
        return fmt.Errorf("failed to post message: %s", err)
    }
    return nil
}

Attachmentは構造体が用意されているので、jsonファイルをUnmarshalします。
※ jsonファイルにはterraformで管理してるプロジェクト名を並べてます。以下サンプルです。

menu.json
{
    "text": "Please select a project to terraform plan :terraform:",
    "color": "#f9a41b",
    "callback_id": "beer",
    "actions": [
        {
            "name": "select",
            "type": "select",
            "options": [
                {
                    "text": "all-project",
                    "value": "all-project all"
                },
                {
                    "text": "a-project",
                    "value": "a-project aws"
                },
                {
                    "text": "b-project",
                    "value": "b-project gcp"
                },
                {
                    "text": "c-project",
                    "value": "c-project aws"
                }
            ]
        },
        {
            "name": "cancel",
            "text": "Cancel",
            "type": "button",
            "style": "danger"
        }
    ]
}

ユーザが選択したInteractive messagesを受け取る

Slack - blnetwork 2019-06-16 16-54-53.png

プロジェクトを選択すると、「are you sure?」と、最終確認のYES/NOのボタンが表示されます。

最初に言えよって感じですが、実はあらかじめここからSlack Appを作成し
Interactive Messageを有効にする必要があります。
そして、Interactive Messageでユーザが選択した結果がSlackからPOSTされます。
と言う事は、それを受けるAPIの口を用意する必要があります。
SlackのWEB画面で以下の様にPOSTを受け取るURLを指定します。

Slack API: Applications | blnetwork Slack 2019-06-15 20-45-10.png

一見分かりにくいので、もう一度言いますと、
1.ユーザがSlackで「Interactive Message」の操作を行う。
2.Slack側からWEB画面で指定したAPI(URL)へユーザが操作した内容をPOSTする。
という事ですので、何かしらPOSTするを処理するHandlerを用意する必要があります。

golang;main.go

    http.Handle("/interaction", interactionHandler{
        verificationToken: VerificationToken,
    })

    log.Printf("[INFO] Server listening on :%s", Port)
    if err := http.ListenAndServe(Port, nil); err != nil {
        log.Printf("[ERROR] %s", err)
    }

そして用意したhandlerですが、ほぼMercariのを参考にさせてもらってます。
「POSTかどうか」のチェックや、「Slack Appに登録したTokenと一致するか」等のValidateをしてます。

handler.go

func (h interactionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // check whether the post message or not.
    if r.Method != http.MethodPost {
        log.Printf("[ERROR] Invalid method: %s", r.Method)
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }

    buf, err := ioutil.ReadAll(r.Body)
    if err != nil {
        log.Printf("[ERROR] Failed to read request body: %s", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    jsonStr, err := url.QueryUnescape(string(buf)[8:])
    if err != nil {
        log.Printf("[ERROR] Failed to unespace request body: %s", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    var message slack.AttachmentActionCallback
    if err := json.Unmarshal([]byte(jsonStr), &message); err != nil {
        log.Printf("[ERROR] Failed to decode json message from slack: %s", jsonStr)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    // Only accept message from slack with valid token
    if message.Token != h.verificationToken {
        log.Printf("[ERROR] Invalid token: %s", message.Token)
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    action := message.ActionCallback.AttachmentActions[0]

    switch action.Name {
    case actionSelect:
        value := action.SelectedOptions[0].Value

        // Overwrite original drop down message.
        originalMessage := message.OriginalMessage
        originalMessage.Attachments[0].Text = fmt.Sprintf("Are you sure? %s", strings.Title(value))
        originalMessage.ResponseType = "in_channel"
        originalMessage.ReplaceOriginal = true
        originalMessage.Attachments[0].Actions = []slack.AttachmentAction{
            {
                Name:  actionStart,
                Text:  "Yes",
                Type:  "button",
                Value: "start",
                Style: "primary",
            },
            {
                Name:  actionCancel,
                Text:  "No",
                Type:  "button",
                Style: "danger",
            },
        }
        w.Header().Add("Content-type", "application/json")
        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(&originalMessage)
        return
    case actionStart:
        prj := message.OriginalMessage.Attachments[0].Text
        prjLower := strings.ToLower(prj)
        prjDiv := prjLower[14:]
        prjSlice := strings.Split(prjDiv, " ")
        cmdArgPrj, cmdArgPrv := prjSlice[0], prjSlice[1]
        title := fmt.Sprintf(":ok: @%s terraform plan started! Just a moment!", message.User.Name)
        responseMessage(w, message.OriginalMessage, title, "")
        err := exec.Command(osScript, cmdArgPrj, cmdArgPrv).Start()
        if err != nil {
            fmt.Printf("Command Exec Error. : %v\n", err)
        }

        return
    case actionCancel:
        title := fmt.Sprintf(":x: @%s canceled the request", message.User.Name)
        responseMessage(w, message.OriginalMessage, title, "")
        return
    default:
        log.Printf("[ERROR] ]Invalid action was submitted: %s", action.Name)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
}

ユーザ選択の結果の処理をshell scriptに渡す

先程のhandlerの抜粋ですが、処理をOS側のscpiptファイルに渡すところまでです。
ユーザが選択した内容を message.OriginalMessage.Attachments[0].Text で拾えます。
prjの変数に「Are you sure? A-project Aws」と、ユーザが選択した内容が含まれていますので、
後はよしなに加工してexec.Command()でshell scriptファイルの引数に渡してあげます。

handler.go

const (
    // action is used for slack attament action.
    actionSelect = "select"
    actionStart  = "start"
    actionCancel = "cancel"
    osScript     = "./terraform.sh"
)

〜〜snip~~

    case actionStart:
        prj := message.OriginalMessage.Attachments[0].Text
        prjLower := strings.ToLower(prj)
        prjDiv := prjLower[14:]
        prjSlice := strings.Split(prjDiv, " ")
        cmdArgPrj, cmdArgPrv := prjSlice[0], prjSlice[1]
        title := fmt.Sprintf(":ok: @%s terraform plan started! Just a moment!", message.User.Name)
        responseMessage(w, message.OriginalMessage, title, "")
        err := exec.Command(osScript, cmdArgPrj, cmdArgPrv).Start()
        if err != nil {
            fmt.Printf("Command Exec Error. : %v\n", err)
        }

        return
~~snip~~

後は、shell芸

最後はshell芸なので詳細は省きますが、terraform planの実行結果をSlack AttachmentにPOSTするためのjsonを組み立ていく感じです。
まず以下のように結果を格納するためのテンプレートのjsonファイルを用意しておきます。

result_tpl.json
{
    "channel": "#terraform",
    "username": "terraform plan check",
    "icon_emoji": ":terraform:",
    "text": "Today's terraformplan\n <https://xxxxx.xxxxx.jp/xxxxx/terrabot|gitlab>",
    "attachments": []
}

各プロジェクトで実行したterraformの結果を以下の様に.attachments[]に追加していく感じです。

result.json
{
    "channel": "#terraform",
    "username": "terraform plan check",
    "icon_emoji": ":terraform:",
    "text": "Today's terraformplan\n <https://xxxxx.xxxxx.jp/xxxxx/terrabot|gitlab>",
    "attachments": [
        {
            "color": "good",
            "title": "project-a",
            "text": "No changes. Infrastructure is up-to-date.",
            "title_link": "https://storage.cloud.google.com/xxx/xxx_201906150900.log"
        },
        {
            "color": "warning",
            "title": "project-b",
            "text": "Plan: 0 to add, 1 to change, 1 to destroy.",
            "title_link": "https://storage.cloud.google.com/xxx/xxx_201906150900.log"
        },
        {
            "color": "warning",
            "title": "project-c",
            "text": "Plan: 0 to add, 1 to change, 0 to destroy.",
            "title_link": "https://storage.cloud.google.com/xxx/xxx_201906150900.log"
        },
        {
            "color": "good",
            "title": "project-d",
            "text": "No changes. Infrastructure is up-to-date.",
            "title_link": "https://storage.cloud.google.com/xxx/xxx_201906150900.log"
        }
    ]
}

最後にできあがったjsonファイルをslack のincoming-webhooksにPOSTして完了です。

curl -X POST ${SLACK_URL} -d @${POST_RESULT_JSON}

地味にハマったのが、shellでjsonを組み立てていく部分です。
jqでやりましが、変数展開が滅多クソハマり、stack overflowで同士を見つけるまで2h程ハマりました。
まぁクォーテーションいっぱいw

SLACK_POST_TPL="/xxx/terrabot/result_tpl.json"
POST_RESULT=$(cat ${SLACK_POST_TPL})

    POST_RESULT=$(echo ${POST_RESULT}| jq '.attachments |= .+[
    {
        "color": "'"${COLOR}"'",
        "title": "'"${1}"'",
        "text": "'"${TEXT}"'",
        "title_link": "'"${GCS}${LOG_FILE}"'"
    }
]')

最後に

botとの Interactiveなやり取り以外はただのshell scriptです。
cronで毎朝9時にslackに結果を通知する様にしています。
そこで差分などがあるとチームメンバで通知があったchannelでそのまま、あーだこーだチャットが始まります。
差分などは後回しにしがちなので、今回の様に1アクションで差分ログを見れる様にしたりなど、少しずつ工夫してます。
皆さんもよいIaCライフを!

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3