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
2 changes: 1 addition & 1 deletion internal/alertmanager/dedup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
func init() {
log.SetLevel(log.ErrorLevel)
for i, uri := range mock.ListAllMockURIs() {
alertmanager.NewAlertmanager(fmt.Sprintf("dedup-mock-%d", i), uri, time.Second)
alertmanager.NewAlertmanager(fmt.Sprintf("dedup-mock-%d", i), uri, time.Second, false)
}
}

Expand Down
23 changes: 22 additions & 1 deletion 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 @@ -29,6 +32,8 @@ type Alertmanager struct {
URI string `json:"uri"`
Timeout 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 Down Expand Up @@ -107,6 +112,22 @@ 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 {
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
19 changes: 10 additions & 9 deletions internal/alertmanager/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var (
)

// NewAlertmanager creates a new Alertmanager instance
func NewAlertmanager(name, uri string, timeout time.Duration) error {
func NewAlertmanager(name, uri string, timeout time.Duration, proxyRequests bool) error {

Choose a reason for hiding this comment

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

I'd suggest using the functional options pattern instead. While reviewing consumer code, it's not clear what the lonesome false means.

Choose a reason for hiding this comment

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

if _, found := upstreams[name]; found {
return fmt.Errorf("Alertmanager upstream '%s' already exist", name)
}
Expand All @@ -27,14 +27,15 @@ func NewAlertmanager(name, uri string, timeout time.Duration) error {
}

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{},
URI: uri,
Timeout: timeout,
Name: name,
ProxyRequests: proxyRequests,
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 Down
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
2 changes: 1 addition & 1 deletion internal/filters/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ var tests = []filterTest{
func TestFilters(t *testing.T) {
log.SetLevel(log.ErrorLevel)

err := alertmanager.NewAlertmanager("test", "http://localhost", time.Second)
err := alertmanager.NewAlertmanager("test", "http://localhost", time.Second, false)
am := alertmanager.GetAlertmanagerByName("test")
if err != nil {
t.Error(err)
Expand Down
5 changes: 4 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func setupRouter(router *gin.Engine) {

func setupUpstreams() {
for _, s := range config.Config.Alertmanager.Servers {
err := alertmanager.NewAlertmanager(s.Name, s.URI, s.Timeout)
err := alertmanager.NewAlertmanager(s.Name, s.URI, s.Timeout, s.Proxy)
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 +151,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
56 changes: 56 additions & 0 deletions proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

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

"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)
}

// 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
req.URL.Path = strings.TrimPrefix(req.URL.Path, proxyPathPrefix(alertmanager.Name))

Choose a reason for hiding this comment

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

Use net/http.StripPrefix instead.

// 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(fmt.Sprintf("%s/api/v1/silences", proxyPathPrefix(alertmanager.Name)), gin.WrapH(proxy))

Choose a reason for hiding this comment

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

Is this not clearer without nesting Sprintfs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not very readable, how about now?

router.DELETE(fmt.Sprintf("%s/api/v1/silence/*id", proxyPathPrefix(alertmanager.Name)), gin.WrapH(proxy))
return nil
}
121 changes: 121 additions & 0 deletions proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/cloudflare/unsee/internal/alertmanager"

httpmock "gopkg.in/jarcoal/httpmock.v1"
)

// httptest.NewRecorder() doesn't implement http.CloseNotifier
type closeNotifyingRecorder struct {

Choose a reason for hiding this comment

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

I don't see you using the CloseNotify method. Is this recorder necessary?

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'm not 100% sure as I didn't go that deep into the issue, but testes were failing because of it and after some research I wasn't sure if this is gin issue or httptest

Choose a reason for hiding this comment

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

I don't have time to research myself, but if you eventually figure it out, I'd like to know.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is that blocking this merge?

Choose a reason for hiding this comment

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

No.

*httptest.ResponseRecorder
closed chan bool
}

func newCloseNotifyingRecorder() *closeNotifyingRecorder {
return &closeNotifyingRecorder{
httptest.NewRecorder(),
make(chan bool, 1),
}
}

func (c *closeNotifyingRecorder) close() {
c.closed <- true
}

func (c *closeNotifyingRecorder) CloseNotify() <-chan bool {
return c.closed
}

type proxyTest struct {
method string
localPath string
upstreamURI string
code int
response string
}

var proxyTests = []proxyTest{
// valid alertmanager and methods
proxyTest{

Choose a reason for hiding this comment

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

struct names are unneeded inside a slice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TIL

method: "POST",
localPath: "/proxy/alertmanager/dummy/api/v1/silences",
upstreamURI: "http://localhost:9093/api/v1/silences",
code: 200,
response: "{\"status\":\"success\",\"data\":{\"silenceId\":\"d8a61ca8-ee2e-4076-999f-276f1e986bf3\"}}",
},
proxyTest{
method: "DELETE",
localPath: "/proxy/alertmanager/dummy/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
code: 200,
response: "{\"status\":\"success\"}",
},
// invalid alertmanager name
proxyTest{
method: "POST",
localPath: "/proxy/alertmanager/INVALID/api/v1/silences",
upstreamURI: "",
code: 404,
response: "404 page not found",
},
proxyTest{
method: "DELETE",
localPath: "/proxy/alertmanager/INVALID/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
code: 404,
response: "404 page not found",
},
// valid alertmanager name, but invalid method
proxyTest{
method: "GET",
localPath: "/proxy/alertmanager/dummy/api/v1/silences",
upstreamURI: "",
code: 404,
response: "404 page not found",
},
proxyTest{
method: "GET",
localPath: "/proxy/alertmanager/dummy/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
upstreamURI: "http://localhost:9093/api/v1/silence/d8a61ca8-ee2e-4076-999f-276f1e986bf3",
code: 404,
response: "404 page not found",
},
}

func TestProxy(t *testing.T) {
r := ginTestEngine()
setupRouterProxyHandlers(r, &alertmanager.Alertmanager{
URI: "http://localhost:9093",
Timeout: time.Second * 5,
Name: "dummy",
ProxyRequests: true,
})

httpmock.Activate()
defer httpmock.DeactivateAndReset()

for _, testCase := range proxyTests {
httpmock.Reset()
if testCase.upstreamURI != "" {
httpmock.RegisterResponder(testCase.method, testCase.upstreamURI, httpmock.NewStringResponder(testCase.code, testCase.response))
}
req, _ := http.NewRequest(testCase.method, testCase.localPath, nil)
resp := newCloseNotifyingRecorder()
r.ServeHTTP(resp, req)
if resp.Code != testCase.code {
t.Errorf("%s %s proxied to %s returned status %d while %d was expected",
testCase.method, testCase.localPath, testCase.upstreamURI, resp.Code, testCase.code)
}
body := resp.Body.String()
if body != testCase.response {
t.Errorf("%s %s proxied to %s returned content '%s' while '%s' was expected",
testCase.method, testCase.localPath, testCase.upstreamURI, body, testCase.response)
}
}
}