Skip to content
This repository has been archived by the owner on Jul 22, 2020. It is now read-only.

Add support for proxying user connection to Alertmanager #202

Merged
merged 8 commits into from
Jan 9, 2018
10 changes: 9 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ alertmanager:
- name: string
uri: string
timeout: duration
proxy: bool
```

* `interval` - how often alerts should be refreshed, a string in
Expand All @@ -70,8 +71,12 @@ alertmanager:
of unsee with `make run`.
* `timeout` - timeout for requests send to this Alertmanager server, a string in
[time.Duration](https://golang.org/pkg/time/#ParseDuration) format.
* `proxy` - if enabled requests from user browsers to this Alertmanager will be
proxied via unsee. This applies to requests made when managing
silences via unsee (creating or expiring silences).

Example:
Example with two production Alertmanager instances running in HA mode and a
staging instance that is also proxied:

```yaml
alertmanager:
Expand All @@ -80,12 +85,15 @@ alertmanager:
- name: production1
uri: https://alertmanager1.prod.example.com
timeout: 20s
proxy: false
- name: production2
uri: https://alertmanager2.prod.example.com
timeout: 20s
proxy: false
- name: staging
uri: https://alertmanager.staging.example.com
timeout: 30s
proxy: true
```

Defaults:
Expand Down
6 changes: 4 additions & 2 deletions docs/example.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
alertmanager:
interval: 60s
servers:
- name: mock
uri: file://internal/mock/0.11.0
- name: local
uri: http://localhost:9093
timeout: 10s
proxy: true
annotations:
default:
hidden: false
Expand All @@ -29,6 +30,7 @@ listen:
port: 8080
prefix: /
log:
config: false
level: info
jira:
- regex: DEVOPS-[0-9]+
Expand Down
4 changes: 3 additions & 1 deletion internal/alertmanager/dedup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import (
func init() {
log.SetLevel(log.ErrorLevel)
for i, uri := range mock.ListAllMockURIs() {
alertmanager.NewAlertmanager(fmt.Sprintf("dedup-mock-%d", i), uri, time.Second)
name := fmt.Sprintf("dedup-mock-%d", i)
am := alertmanager.NewAlertmanager(name, uri, alertmanager.WithRequestTimeout(time.Second))
alertmanager.RegisterAlertmanager(am)
}
}

Expand Down
35 changes: 28 additions & 7 deletions internal/alertmanager/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package alertmanager

import (
"fmt"
"path"
"sort"
"strings"
"sync"
"time"

"github.com/cloudflare/unsee/internal/config"
"github.com/cloudflare/unsee/internal/mapper"
"github.com/cloudflare/unsee/internal/models"
"github.com/cloudflare/unsee/internal/transform"
Expand All @@ -26,9 +29,11 @@ type alertmanagerMetrics struct {

// Alertmanager represents Alertmanager upstream instance
type Alertmanager struct {
URI string `json:"uri"`
Timeout time.Duration `json:"timeout"`
Name string `json:"name"`
URI string `json:"uri"`
RequestTimeout time.Duration `json:"timeout"`
Name string `json:"name"`
// whenever this instance should be proxied
ProxyRequests bool
// lock protects data access while updating
lock sync.RWMutex
// fields for storing pulled data
Expand All @@ -51,7 +56,7 @@ func (am *Alertmanager) detectVersion() string {
return defaultVersion
}
ver := alertmanagerVersion{}
err = transport.ReadJSON(url, am.Timeout, &ver)
err = transport.ReadJSON(url, am.RequestTimeout, &ver)
if err != nil {
log.Errorf("[%s] %s request failed: %s", am.Name, url, err.Error())
return defaultVersion
Expand Down Expand Up @@ -87,7 +92,7 @@ func (am *Alertmanager) pullSilences(version string) error {
}

start := time.Now()
silences, err := mapper.GetSilences(am.URI, am.Timeout)
silences, err := mapper.GetSilences(am.URI, am.RequestTimeout)
if err != nil {
return err
}
Expand All @@ -107,14 +112,30 @@ func (am *Alertmanager) pullSilences(version string) error {
return nil
}

// this is the URI of this Alertmanager we put in JSON reponse
// it's either real full URI or a proxy relative URI
func (am *Alertmanager) publicURI() string {
if am.ProxyRequests {
sub := fmt.Sprintf("/proxy/alertmanager/%s", am.Name)
uri := path.Join(config.Config.Listen.Prefix, sub)
if strings.HasSuffix(sub, "/") {
// if sub path had trailing slash then add it here, since path.Join will
// skip it
return uri + "/"
}
return uri
}
return am.URI
}

func (am *Alertmanager) pullAlerts(version string) error {
mapper, err := mapper.GetAlertMapper(version)
if err != nil {
return err
}

start := time.Now()
groups, err := mapper.GetAlerts(am.URI, am.Timeout)
groups, err := mapper.GetAlerts(am.URI, am.RequestTimeout)
if err != nil {
return err
}
Expand Down Expand Up @@ -163,7 +184,7 @@ func (am *Alertmanager) pullAlerts(version string) error {
alert.Alertmanager = []models.AlertmanagerInstance{
models.AlertmanagerInstance{
Name: am.Name,
URI: am.URI,
URI: am.publicURI(),
State: alert.State,
StartsAt: alert.StartsAt,
EndsAt: alert.EndsAt,
Expand Down
70 changes: 49 additions & 21 deletions internal/alertmanager/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,24 @@ import (
log "github.com/sirupsen/logrus"
)

// Option allows to pass functional options to NewAlertmanager()
type Option func(am *Alertmanager)

var (
upstreams = map[string]*Alertmanager{}
)

// NewAlertmanager creates a new Alertmanager instance
func NewAlertmanager(name, uri string, timeout time.Duration) error {
if _, found := upstreams[name]; found {
return fmt.Errorf("Alertmanager upstream '%s' already exist", name)
}

for _, am := range upstreams {
if am.URI == uri {
return fmt.Errorf("Alertmanager upstream '%s' already collects from '%s'", am.Name, am.URI)
}
}

upstreams[name] = &Alertmanager{
URI: uri,
Timeout: timeout,
Name: name,
lock: sync.RWMutex{},
alertGroups: []models.AlertGroup{},
silences: map[string]models.Silence{},
colors: models.LabelsColorMap{},
autocomplete: []models.Autocomplete{},
func NewAlertmanager(name, uri string, opts ...Option) *Alertmanager {
am := &Alertmanager{
URI: uri,
RequestTimeout: time.Second * 10,
Name: name,
lock: sync.RWMutex{},
alertGroups: []models.AlertGroup{},
silences: map[string]models.Silence{},
colors: models.LabelsColorMap{},
autocomplete: []models.Autocomplete{},
metrics: alertmanagerMetrics{
errors: map[string]float64{
labelValueErrorsAlerts: 0,
Expand All @@ -43,8 +36,27 @@ func NewAlertmanager(name, uri string, timeout time.Duration) error {
},
}

log.Infof("[%s] Configured Alertmanager source at %s", name, uri)
for _, opt := range opts {
opt(am)
}

return am
}

// RegisterAlertmanager will add an Alertmanager instance to the list of
// instances used when pulling alerts from upstreams
func RegisterAlertmanager(am *Alertmanager) error {
if _, found := upstreams[am.Name]; found {
return fmt.Errorf("Alertmanager upstream '%s' already exist", am.Name)
}

for _, existingAM := range upstreams {
if existingAM.URI == am.URI {
return fmt.Errorf("Alertmanager upstream '%s' already collects from '%s'", existingAM.Name, existingAM.URI)
}
}
upstreams[am.Name] = am
log.Infof("[%s] Configured Alertmanager source at %s (proxied: %v)", am.Name, am.URI, am.ProxyRequests)
return nil
}

Expand All @@ -66,3 +78,19 @@ func GetAlertmanagerByName(name string) *Alertmanager {
}
return nil
}

// WithProxy option can be passed to NewAlertmanager in order to enable request
// proxying for unsee clients
func WithProxy(proxied bool) Option {
return func(am *Alertmanager) {
am.ProxyRequests = proxied
}
}

// WithRequestTimeout option can be passed to NewAlertmanager in order to set
// a custom timeout for Alertmanager upstream requests
func WithRequestTimeout(timeout time.Duration) Option {
return func(am *Alertmanager) {
am.RequestTimeout = timeout
}
}
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func testReadConfig(t *testing.T) {
- name: default
uri: http://localhost
timeout: 40s
proxy: false
annotations:
default:
hidden: true
Expand Down
1 change: 1 addition & 0 deletions internal/config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type alertmanagerConfig struct {
Name string
URI string
Timeout time.Duration
Proxy bool
}

type jiraRule struct {
Expand Down
6 changes: 1 addition & 5 deletions internal/filters/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,7 @@ var tests = []filterTest{
func TestFilters(t *testing.T) {
log.SetLevel(log.ErrorLevel)

err := alertmanager.NewAlertmanager("test", "http://localhost", time.Second)
am := alertmanager.GetAlertmanagerByName("test")
if err != nil {
t.Error(err)
}
am := alertmanager.NewAlertmanager("test", "http://localhost", alertmanager.WithRequestTimeout(time.Second))
for _, ft := range tests {
alert := models.Alert(ft.Alert)
if &ft.Silence != nil {
Expand Down
6 changes: 5 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func setupRouter(router *gin.Engine) {

func setupUpstreams() {
for _, s := range config.Config.Alertmanager.Servers {
err := alertmanager.NewAlertmanager(s.Name, s.URI, s.Timeout)
am := alertmanager.NewAlertmanager(s.Name, s.URI, alertmanager.WithRequestTimeout(s.Timeout), alertmanager.WithProxy(s.Proxy))
err := alertmanager.RegisterAlertmanager(am)
if err != nil {
log.Fatalf("Failed to configure Alertmanager '%s' with URI '%s': %s", s.Name, s.URI, err)
}
Expand Down Expand Up @@ -151,6 +152,9 @@ func main() {
}

setupRouter(router)
for _, am := range alertmanager.GetAlertmanagers() {
setupRouterProxyHandlers(router, am)
}
listen := fmt.Sprintf("%s:%d", config.Config.Listen.Address, config.Config.Listen.Port)
log.Infof("Listening on %s", listen)
err := router.Run(listen)
Expand Down
62 changes: 62 additions & 0 deletions proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"

"github.com/cloudflare/unsee/internal/alertmanager"
"github.com/cloudflare/unsee/internal/config"
"github.com/gin-gonic/gin"

log "github.com/sirupsen/logrus"
)

func proxyPathPrefix(name string) string {
return fmt.Sprintf("%sproxy/alertmanager/%s", config.Config.Listen.Prefix, name)
}

func proxyPath(name, path string) string {
return fmt.Sprintf("%s%s", proxyPathPrefix(name), path)
}

// NewAlertmanagerProxy creates a proxy instance for given alertmanager instance
func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.ReverseProxy, error) {
upstreamURL, err := url.Parse(alertmanager.URI)
if err != nil {
return nil, err
}
proxy := httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = upstreamURL.Scheme
req.URL.Host = upstreamURL.Host
// drop Accept-Encoding header so we always get uncompressed reponses from
// upstream, there's a gzip middleware that's global so we don't want it
// to gzip twice
req.Header.Del("Accept-Encoding")
log.Debugf("[%s] Proxy request for %s", alertmanager.Name, req.URL.Path)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to accept a logger?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how that would be useful right now

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly useful for configuring the logging for proxy specifically. But mostly a nit.

},
ModifyResponse: func(resp *http.Response) error {
// drop Content-Length header from upstream responses, gzip middleware
// will compress those and that could cause a mismatch
resp.Header.Del("Content-Length")
return nil
},
}
return &proxy, nil
}

func setupRouterProxyHandlers(router *gin.Engine, alertmanager *alertmanager.Alertmanager) error {
proxy, err := NewAlertmanagerProxy(alertmanager)
if err != nil {
return err
}
router.POST(
proxyPath(alertmanager.Name, "/api/v1/silences"),
gin.WrapH(http.StripPrefix(proxyPathPrefix(alertmanager.Name), proxy)))
router.DELETE(
proxyPath(alertmanager.Name, "/api/v1/silence/*id"),
gin.WrapH(http.StripPrefix(proxyPathPrefix(alertmanager.Name), proxy)))
return nil
}