AlertmanagerのSilence作成をslackに通知する

最近PrometheusのAlertmanagerを使う機会があったので、ちょっとしたツールを作ったのでメモ。

## 何が作りたかったか
AlertmanagerではSilenceを登録することで、発生したアラートの通知有無を制御することができる。
SilenceはActive, Pending, Expiredの３つのステータスを持っていて、指定時間が経過すると勝手にステータスが遷移する。
しかし、ステータス遷移は都度確認しないとわからないし、Expiredされて数時間経ったSilenceはGCにより削除されてしまうため、履歴も追えない。
これだとオペレーションを後追いできず、若干不便なので、Silenceのステータス変更をslackに通知するツールを作りたい。

## 実行
内容はシンプルで、Alertmanager APIで定期的にSilenceを取得し、前回取得時との差分をSlackに通知しているだけ。

https://github.com/m-masataka/alertmanager-silence-notifier/

APIでの取得先hostとかportとかを指定すればOK

```
./notifier \ 
 --alertmanager.host=localhost \
 --alertmanager.port=9093 \
 --slack.username=Bot \
 --slack.channel=general \
 --slack.token={your webhook token} \
 --interval=10s \
 --timerange=5m
```
* 一定間隔でAPIからSilenceを取得
* 前回取得との差分を抽出
* slackに出力

これを繰り返す感じ。
APIでsilenceをいい感じに取得するライブラリは本家alertmanagerに用意されているし、
slackのwebhookにメッセージを投げるライブラリも既に存在した。[go-slack-webhook](https://github.com/ashwanthkumar/slack-go-webhook)
なので、ほとんど手作りの物はなくいい感じ。

↓のように通知される。
<img width="438" alt="slack_image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520924/a1762d63-56f3-0e6b-f252-319be8fa95b1.png">

```
package main

import (
	"fmt"
	"os"
	"time"
	"net/url"
	"gopkg.in/alecthomas/kingpin.v2"
	"github.com/prometheus/alertmanager/api/v2/client/silence"
	"github.com/prometheus/alertmanager/api/v2/models"
	"github.com/prometheus/alertmanager/cli"
	"github.com/ashwanthkumar/slack-go-webhook"
)

func main() {
	os.Exit(run())
}

type IdAndState struct {
	id string
	state string
}

func run() int {
	var (
		host         = kingpin.Flag("alertmanager.host", "Alertmanager host.").Default("localhost").String()
		port         = kingpin.Flag("alertmanager.port", "Alertmanager port.").Default("9093").String()
		username     = kingpin.Flag("slack.username", "username of slack bot.").Default("Bot").String()
		channel      = kingpin.Flag("slack.channel", "post channel.").Default("general").String()
		token        = kingpin.Flag("slack.token", "slack api token.").Default("xxx").String()
		interval     = kingpin.Flag("interval", "api polling interval.").Default("5s").Duration()
		timerange    = kingpin.Flag("timerange", "api polling time range.").Default("5m").Duration()
	)
	kingpin.CommandLine.GetFlag("help").Short('h')
	kingpin.Parse()

	u, _ := url.Parse("http://" + *host + ":" + *port)
	silenceParams := silence.NewGetSilencesParams()
	amclient := cli.NewAlertmanagerClient(u)

	prev := make([]IdAndState, 0)
	getOk, err := amclient.Silence.GetSilences(silenceParams)
	if err != nil {
		fmt.Println(err)
		return 1
	}
	for _, silence := range getOk.Payload {
		if time.Time(*silence.EndsAt).After(time.Now().UTC().Add(- *timerange)) {
			prev = append(prev, IdAndState{*silence.ID, *silence.Status.State})
		}
	}

	for {
		tmp := make([]IdAndState, 0)
		getOk, err = amclient.Silence.GetSilences(silenceParams)
		if err != nil {
			fmt.Println(err)
		} else {
			for _, silence := range getOk.Payload {
				if time.Time(*silence.EndsAt).After(time.Now().UTC().Add(-5 * time.Minute)) {
					if CompareSilences(prev, *silence.ID, *silence.Status.State) {
						PostSlack(*silence,*username,*channel,*token, *host, *port)
					}
					tmp = append(tmp, IdAndState{*silence.ID, *silence.Status.State})
				}
			}
			prev = tmp
		}
		time.Sleep(*interval)
	}
	return 0
}

func CompareSilences(list []IdAndState, id string, state string) bool {
	for _, v := range list {
		if v.id == id && v.state == state {
			return false
		}
	}
	return true
}

func PostSlack(s models.GettableSilence, username string, channel string, token string, host string, port string) error {
	titleStr := *s.Status.State + " : " + *s.ID
	valueStr := "Starts at: " + s.Silence.StartsAt.String() + "\n" +
		"Ends at     : " + s.Silence.EndsAt.String() + "\n" +
		"Updated at  : " + s.UpdatedAt.String() + "\n" +
		"Created by  : " + *s.Silence.CreatedBy + "\n" +
		"Comment     : " + *s.Silence.Comment + "\n" +
		"Matchers:\n"
	for _, matcher := range s.Silence.Matchers {
		var operator string
		if *matcher.IsRegex {
			operator = "~="
		} else {
			operator = "="
		}
		valueStr += *matcher.Name + operator + *matcher.Value + "\n"
	}
	silenceUrl := "http://" + host + ":" + port + "/#/silences/" + *s.ID
	attachment := slack.Attachment{}
	attachment.AddField(slack.Field{ Title: titleStr, Value: valueStr })
	attachment.AddAction(slack.Action { Type: "button", Text: "View", Url: silenceUrl })
	var color string
	var msg string
	if *s.Status.State == "active" {
		color = "good"
		msg = "New Silence!"
	} else if *s.Status.State == "pending" {
		color = "warning"
		msg = "New Silence!"
	} else {
		color = "danger"
		msg = "Expired Silence!"
	}
	attachment.Color = &color
	payload := slack.Payload {
		Username: username,
		Channel: channel,
		Text: msg,
		Attachments: []slack.Attachment{attachment},
	}
	err := slack.Send("https://hooks.slack.com/services/" + token, "", payload)
	if err != nil {
		return err[0]
	}
	return nil
}
```