mirror of
https://github.com/prymitive/karma
synced 2026-08-28 11:17:28 +00:00
Add support for proxying user connection to Alertmanager
Fixes #190. With this feature unsee can be configured to proxy requests to selected Alertmanager instances, if it's enabled unsee silence form will send a request via unsee rather than directly. This allows users to manage silences in environments where they have access to unsee but not to Alertmanager. Only silences endpoints on Alertmanager API are proxied.
This commit is contained in:
@@ -49,6 +49,7 @@ alertmanager:
|
||||
- name: string
|
||||
uri: string
|
||||
timeout: duration
|
||||
proxy: bool
|
||||
```
|
||||
|
||||
* `interval` - how often alerts should be refreshed, a string in
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
+4
-2
@@ -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
|
||||
@@ -29,6 +30,7 @@ listen:
|
||||
port: 8080
|
||||
prefix: /
|
||||
log:
|
||||
config: false
|
||||
level: info
|
||||
jira:
|
||||
- regex: DEVOPS-[0-9]+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
if _, found := upstreams[name]; found {
|
||||
return fmt.Errorf("Alertmanager upstream '%s' already exist", name)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ func testReadConfig(t *testing.T) {
|
||||
- name: default
|
||||
uri: http://localhost
|
||||
timeout: 40s
|
||||
proxy: false
|
||||
annotations:
|
||||
default:
|
||||
hidden: true
|
||||
|
||||
@@ -6,6 +6,7 @@ type alertmanagerConfig struct {
|
||||
Name string
|
||||
URI string
|
||||
Timeout time.Duration
|
||||
Proxy bool
|
||||
}
|
||||
|
||||
type jiraRule struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
// 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)
|
||||
},
|
||||
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))
|
||||
router.DELETE(fmt.Sprintf("%s/api/v1/silence/*id", proxyPathPrefix(alertmanager.Name)), gin.WrapH(proxy))
|
||||
return nil
|
||||
}
|
||||
+121
@@ -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 {
|
||||
*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{
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user