mirror of
https://github.com/prymitive/karma
synced 2026-08-23 11:56:20 +00:00
Merge pull request #1380 from prymitive/ro
feat(ui): disable silence actions for read-only alertmanagers
This commit is contained in:
@@ -126,14 +126,23 @@ Alertmanager version.
|
||||
|
||||
## Security
|
||||
|
||||
The karma process doesn't send any API request to the Alertmanager that could
|
||||
modify alerts or silence state, but it does provide a web interface that allows
|
||||
a user to send such requests directly to the Alertmanager API.
|
||||
If you wish to deploy karma as a read-only tool please ensure that:
|
||||
karma doesn't in any way alter alerts in any Alertmanager instance it collects
|
||||
data from. This is true for both the backend and the web UI.
|
||||
The web UI allows to manage silences by sending requests to Alertmanager
|
||||
instances, this can be done directly (browser to Alertmanager API) or by
|
||||
proxying such requests via karma backend (browser to karma backend to
|
||||
Alertmanager API) if `proxy` mode is enabled in karma config.
|
||||
|
||||
If you wish to deploy karma as a read-only tool without giving users any ability
|
||||
to modify data in Alertmanager instance, then please ensure that:
|
||||
|
||||
- the karma process is able to connect to the Alertmanager API
|
||||
- read-only users are able to connect to the karma web interface
|
||||
- read-only users are NOT able to connect to the Alertmanager API
|
||||
- `readonly` is set to `true` in
|
||||
[alertmanager:servers](/docs/CONFIGURATION.md#alertmanagers) config section
|
||||
for all alertmanager instances, this options will disable any UI elements that
|
||||
could trigger updates (like silence management)
|
||||
|
||||
## Metrics
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ func getUpstreams() models.AlertmanagerAPISummary {
|
||||
Name: upstream.Name,
|
||||
URI: upstream.InternalURI(),
|
||||
PublicURI: upstream.PublicURI(),
|
||||
ReadOnly: upstream.ReadOnly,
|
||||
Headers: map[string]string{},
|
||||
Error: upstream.Error(),
|
||||
Version: upstream.Version(),
|
||||
|
||||
@@ -120,6 +120,10 @@ func setupMetrics(router *gin.Engine) {
|
||||
func setupUpstreams() error {
|
||||
for _, s := range config.Config.Alertmanager.Servers {
|
||||
|
||||
if s.Proxy && s.ReadOnly {
|
||||
return fmt.Errorf("Failed to create Alertmanager '%s' with URI '%s': cannot use proxy and readonly mode at the same time", s.Name, uri.SanitizeURI(s.URI))
|
||||
}
|
||||
|
||||
var httpTransport http.RoundTripper
|
||||
var err error
|
||||
// if either TLS root CA or client cert is configured then initialize custom transport where we have this setup
|
||||
@@ -136,6 +140,7 @@ func setupUpstreams() error {
|
||||
alertmanager.WithExternalURI(s.ExternalURI),
|
||||
alertmanager.WithRequestTimeout(s.Timeout),
|
||||
alertmanager.WithProxy(s.Proxy),
|
||||
alertmanager.WithReadOnly(s.ReadOnly),
|
||||
alertmanager.WithHTTPTransport(httpTransport), // we will pass a nil unless TLS.CA or TLS.Cert is set
|
||||
alertmanager.WithHTTPHeaders(s.Headers),
|
||||
)
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Check if proxy mode is set correctly
|
||||
karma.bin-should-fail --log.format=text --log.config=true --config.file=karma.yaml --check-config
|
||||
! stdout .
|
||||
stderr 'msg="Failed to create Alertmanager ''failed'' with URI ''http://localhost'': cannot use proxy and readonly mode at the same time"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: failed
|
||||
uri: http://localhost
|
||||
proxy: true
|
||||
readonly: true
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# Check if proxy mode is set correctly
|
||||
karma.bin-should-work --log.format=text --log.config=true --config.file=karma.yaml --check-config
|
||||
! stdout .
|
||||
stderr 'msg=" proxy: true"'
|
||||
stderr 'msg="\[proxied\] Configured Alertmanager source at http://localhost \(proxied: true\, readonly: false\)"'
|
||||
! stderr 'level=error'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: proxied
|
||||
uri: http://localhost
|
||||
proxy: true
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Check if readonly mode is set correctly
|
||||
karma.bin-should-work --log.format=text --log.config=true --config.file=karma.yaml --check-config
|
||||
! stdout .
|
||||
stderr 'msg="\[readonly\] Configured Alertmanager source at http://localhost \(proxied: false\, readonly: true\)"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: readonly
|
||||
uri: http://localhost
|
||||
readonly: true
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
karma.bin-should-work --log.format=text --log.config=false --alertmanager.uri=http://localhost
|
||||
! stdout .
|
||||
stderr 'msg="Using simple config with a single Alertmanager server"'
|
||||
stderr 'msg="\[default\] Configured Alertmanager source at http://localhost \(proxied: false\)"'
|
||||
stderr 'msg="\[default\] Configured Alertmanager source at http://localhost \(proxied: false\, readonly: false\)"'
|
||||
|
||||
+18
-1
@@ -42,6 +42,7 @@ alertmanager:
|
||||
external_uri: string
|
||||
timeout: duration
|
||||
proxy: bool
|
||||
readonly: bool
|
||||
tls:
|
||||
ca: string
|
||||
cert: string
|
||||
@@ -81,7 +82,10 @@ alertmanager:
|
||||
- `proxy` - if enabled requests from user browsers to this Alertmanager will be
|
||||
proxied via karma. This applies to requests made when managing silences via
|
||||
karma (creating or expiring silences).
|
||||
THis option cannot be used when `external_uri` is set.
|
||||
This option cannot be used when `readonly` is enabled.
|
||||
- `readonly` - set this Alertmanager upstream to a read only mode. This will
|
||||
disallow silence creation or editing.
|
||||
This option cannot be used when `proxy` is enabled.
|
||||
- `tls:ca` - path to CA certificate used to establish TLS connection to this
|
||||
Alertmanager instance (for URIs using `https://` scheme). If unset or empty
|
||||
string is set then Go will try to find system CA certificates using well known
|
||||
@@ -181,6 +185,19 @@ Breakdown of all combination of settings:
|
||||
|-|-|-|
|
||||
| `http://localhost:123` | Karma internal URI | `http://example.com` |
|
||||
|
||||
1. ReadOnly mode is enabled:
|
||||
|
||||
```YAML
|
||||
uri: http://localhost:123
|
||||
readonly: true
|
||||
```
|
||||
|
||||
Karma would use those URIs for:
|
||||
|
||||
| Backend | Silence management | Silence links |
|
||||
|-|-|-|
|
||||
| `http://localhost:123` | Disabled | `http://localhost:123` |
|
||||
|
||||
Example with two production Alertmanager instances running in HA mode and a
|
||||
staging instance that is also proxied and requires a custom auth header:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ alertmanager:
|
||||
uri: http://localhost:9093
|
||||
timeout: 10s
|
||||
proxy: true
|
||||
readonly: false
|
||||
headers:
|
||||
X-Auth-Test: some-token-or-other-string
|
||||
- name: client-auth
|
||||
|
||||
@@ -39,6 +39,7 @@ type Alertmanager struct {
|
||||
Name string `json:"name"`
|
||||
// whenever this instance should be proxied
|
||||
ProxyRequests bool `json:"proxyRequests"`
|
||||
ReadOnly bool `json:"readonly"`
|
||||
// reader instances are specific to URI scheme we collect from
|
||||
reader uri.Reader
|
||||
// implements how we fetch requests from the Alertmanager, we don't set it
|
||||
|
||||
@@ -73,7 +73,7 @@ func RegisterAlertmanager(am *Alertmanager) error {
|
||||
}
|
||||
}
|
||||
upstreams[am.Name] = am
|
||||
log.Infof("[%s] Configured Alertmanager source at %s (proxied: %v)", am.Name, uri.SanitizeURI(am.URI), am.ProxyRequests)
|
||||
log.Infof("[%s] Configured Alertmanager source at %s (proxied: %v, readonly: %v)", am.Name, uri.SanitizeURI(am.URI), am.ProxyRequests, am.ReadOnly)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -117,6 +117,15 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithReadOnly option can be passed to NewAlertmanager in order to configure
|
||||
// it as read-only, in that mode it doesn't allow creating silences via Proxy
|
||||
func WithReadOnly(readonly bool) Option {
|
||||
return func(am *Alertmanager) error {
|
||||
am.ReadOnly = readonly
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPHeaders option can be passed to NewAlertManager in order to set
|
||||
// a map of headers that will be passed with every request
|
||||
func WithHTTPHeaders(headers map[string]string) Option {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package alertmanager
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prymitive/karma/internal/config"
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
config config.AlertmanagerConfig
|
||||
}
|
||||
|
||||
var testCases = []testCase{
|
||||
{
|
||||
config: config.AlertmanagerConfig{
|
||||
Name: "name",
|
||||
URI: "http://localhost:9093",
|
||||
ExternalURI: "http://localhost:9093",
|
||||
Timeout: time.Second * 30,
|
||||
Proxy: false,
|
||||
ReadOnly: false,
|
||||
Headers: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
config: config.AlertmanagerConfig{
|
||||
Name: "proxy",
|
||||
URI: "http://localhost:9094",
|
||||
ExternalURI: "http://localhost:9094",
|
||||
Timeout: time.Second * 30,
|
||||
Proxy: true,
|
||||
ReadOnly: false,
|
||||
Headers: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
config: config.AlertmanagerConfig{
|
||||
Name: "name",
|
||||
URI: "http://localhost:9095",
|
||||
ExternalURI: "http://localhost:9095",
|
||||
Timeout: time.Second * 30,
|
||||
Proxy: false,
|
||||
ReadOnly: true,
|
||||
Headers: map[string]string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
var httpTransport http.RoundTripper
|
||||
var err error
|
||||
if tc.config.TLS.CA != "" || tc.config.TLS.Cert != "" || tc.config.TLS.InsecureSkipVerify {
|
||||
httpTransport, err = NewHTTPTransport(tc.config.TLS.CA, tc.config.TLS.Cert, tc.config.TLS.Key, tc.config.TLS.InsecureSkipVerify)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create HTTP transport for Alertmanager '%s' with URI '%s': %s", tc.config.Name, tc.config.URI, err)
|
||||
}
|
||||
}
|
||||
|
||||
am, err := NewAlertmanager(
|
||||
tc.config.Name,
|
||||
tc.config.URI,
|
||||
WithExternalURI(tc.config.ExternalURI),
|
||||
WithRequestTimeout(tc.config.Timeout),
|
||||
WithProxy(tc.config.Proxy),
|
||||
WithReadOnly(tc.config.ReadOnly),
|
||||
WithHTTPTransport(httpTransport), // we will pass a nil unless TLS.CA or TLS.Cert is set
|
||||
WithHTTPHeaders(tc.config.Headers),
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create Alertmanager '%s' with URI '%s': %s", tc.config.Name, tc.config.URI, err)
|
||||
}
|
||||
if am.Name != tc.config.Name {
|
||||
t.Errorf("AlertmanagerConfig with name '%s' returned Alertmanager with name '%s'", tc.config.Name, am.Name)
|
||||
}
|
||||
if am.URI != tc.config.URI {
|
||||
t.Errorf("AlertmanagerConfig with name '%s' and URI '%s' returned Alertmanager with URI '%s'", tc.config.Name, tc.config.URI, am.URI)
|
||||
}
|
||||
if am.ProxyRequests != tc.config.Proxy {
|
||||
t.Errorf("AlertmanagerConfig with name '%s' and proxy '%v' returned Alertmanager with proxy '%v'", tc.config.Name, tc.config.Proxy, am.ProxyRequests)
|
||||
}
|
||||
if am.ReadOnly != tc.config.ReadOnly {
|
||||
t.Errorf("AlertmanagerConfig with name '%s' and readonly '%v' returned Alertmanager with readonly '%v'", tc.config.Name, tc.config.ReadOnly, am.ReadOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ func init() {
|
||||
"Timeout for requests sent to the Alertmanager server (only used with simplified config)")
|
||||
pflag.Bool("alertmanager.proxy", false,
|
||||
"Proxy all client requests to Alertmanager via karma (only used with simplified config)")
|
||||
pflag.Bool("alertmanager.readonly", false,
|
||||
"Enable read-only mode that disable silence management (only used with simplified config)")
|
||||
|
||||
pflag.String("karma.name", "karma", "Name for the karma instance")
|
||||
|
||||
@@ -156,7 +158,7 @@ func (config *configSchema) Read() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
config.Alertmanager.Servers = []alertmanagerConfig{}
|
||||
config.Alertmanager.Servers = []AlertmanagerConfig{}
|
||||
config.Alertmanager.Interval = v.GetDuration("alertmanager.interval")
|
||||
config.AlertAcknowledgement.Enabled = v.GetBool("alertAcknowledgement.enabled")
|
||||
config.AlertAcknowledgement.Author = v.GetString("alertAcknowledgement.author")
|
||||
@@ -282,13 +284,14 @@ func (config *configSchema) Read() {
|
||||
// accept single Alertmanager server from flag/env if nothing is set yet
|
||||
if len(config.Alertmanager.Servers) == 0 && v.GetString("alertmanager.uri") != "" {
|
||||
log.Info("Using simple config with a single Alertmanager server")
|
||||
config.Alertmanager.Servers = []alertmanagerConfig{
|
||||
config.Alertmanager.Servers = []AlertmanagerConfig{
|
||||
{
|
||||
Name: v.GetString("alertmanager.name"),
|
||||
URI: v.GetString("alertmanager.uri"),
|
||||
ExternalURI: v.GetString("alertmanager.external_uri"),
|
||||
Timeout: v.GetDuration("alertmanager.timeout"),
|
||||
Proxy: v.GetBool("alertmanager.proxy"),
|
||||
ReadOnly: v.GetBool("alertmanager.readonly"),
|
||||
Headers: make(map[string]string),
|
||||
},
|
||||
}
|
||||
@@ -301,15 +304,16 @@ func (config *configSchema) LogValues() {
|
||||
cfg := configSchema(*config)
|
||||
|
||||
// replace passwords in Alertmanager URIs with 'xxx'
|
||||
servers := []alertmanagerConfig{}
|
||||
servers := []AlertmanagerConfig{}
|
||||
for _, s := range cfg.Alertmanager.Servers {
|
||||
server := alertmanagerConfig{
|
||||
server := AlertmanagerConfig{
|
||||
Name: s.Name,
|
||||
URI: uri.SanitizeURI(s.URI),
|
||||
ExternalURI: uri.SanitizeURI(s.ExternalURI),
|
||||
Timeout: s.Timeout,
|
||||
TLS: s.TLS,
|
||||
Proxy: s.Proxy,
|
||||
ReadOnly: s.ReadOnly,
|
||||
Headers: s.Headers,
|
||||
}
|
||||
servers = append(servers, server)
|
||||
|
||||
@@ -63,6 +63,7 @@ func testReadConfig(t *testing.T) {
|
||||
external_uri: http://example.com
|
||||
timeout: 40s
|
||||
proxy: false
|
||||
readonly: false
|
||||
tls:
|
||||
ca: ""
|
||||
cert: ""
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertmanagerConfig struct {
|
||||
type AlertmanagerConfig struct {
|
||||
Name string
|
||||
URI string
|
||||
ExternalURI string `yaml:"external_uri" mapstructure:"external_uri"`
|
||||
Timeout time.Duration
|
||||
Proxy bool
|
||||
ReadOnly bool `yaml:"readonly" mapstructure:"readonly"`
|
||||
TLS struct {
|
||||
CA string
|
||||
Cert string
|
||||
@@ -37,7 +38,7 @@ type CustomLabelColors map[string][]CustomLabelColor
|
||||
type configSchema struct {
|
||||
Alertmanager struct {
|
||||
Interval time.Duration
|
||||
Servers []alertmanagerConfig
|
||||
Servers []AlertmanagerConfig
|
||||
}
|
||||
AlertAcknowledgement struct {
|
||||
Enabled bool
|
||||
|
||||
@@ -29,6 +29,7 @@ type AlertmanagerAPIStatus struct {
|
||||
URI string `json:"uri"`
|
||||
// this is the Alertmanager URI used for links in the browser
|
||||
PublicURI string `json:"publicURI"`
|
||||
ReadOnly bool `json:"readonly"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Error string `json:"error"`
|
||||
Version string `json:"version"`
|
||||
|
||||
@@ -192,7 +192,7 @@ const AlertAck = observer(
|
||||
.filter(([amName, alertCount]) => alertCount > 0)
|
||||
.map(([amName, _]) => amName);
|
||||
const clusters = Object.entries(
|
||||
alertStore.data.upstreams.clusters
|
||||
alertStore.data.clustersWithoutReadOnly
|
||||
).filter(([clusterName, clusterMembers]) =>
|
||||
alertmanagers.some(m => clusterMembers.includes(m))
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ beforeEach(() => {
|
||||
name: "default",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
headers: { foo: "bar" },
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -287,6 +288,7 @@ describe("<AlertAck />", () => {
|
||||
name: "default",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -297,6 +299,7 @@ describe("<AlertAck />", () => {
|
||||
name: "fallback",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -334,6 +337,7 @@ describe("<AlertAck />", () => {
|
||||
name: "default",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.16.2",
|
||||
@@ -344,6 +348,7 @@ describe("<AlertAck />", () => {
|
||||
name: "fallback",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.16.2",
|
||||
@@ -381,6 +386,7 @@ describe("<AlertAck />", () => {
|
||||
name: "default",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
|
||||
@@ -113,6 +113,7 @@ const DeleteSilenceModalContent = observer(
|
||||
getAlertmanager = () =>
|
||||
this.props.alertStore.data.upstreams.instances
|
||||
.filter(u => u.cluster === this.props.cluster)
|
||||
.filter(u => u.readonly === false)
|
||||
.slice(0, 1)[0];
|
||||
|
||||
parseAlertmanagerResponse = response => {
|
||||
@@ -296,11 +297,16 @@ const DeleteSilence = observer(
|
||||
onModalExit
|
||||
} = this.props;
|
||||
|
||||
const members = alertStore.data.getClusterAlertmanagersWithoutReadOnly(
|
||||
cluster
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={this.toggle.toggle}
|
||||
disabled={members.length === 0}
|
||||
onClick={members.length && this.toggle.toggle}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1 d-none d-sm-inline-block"
|
||||
|
||||
@@ -26,6 +26,7 @@ beforeEach(() => {
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
|
||||
@@ -44,6 +44,7 @@ const MockMultipleClusters = () => {
|
||||
name: "default",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -54,6 +55,7 @@ const MockMultipleClusters = () => {
|
||||
name: "fallback",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -64,6 +66,7 @@ const MockMultipleClusters = () => {
|
||||
name: "second",
|
||||
uri: "http://am3.example.com",
|
||||
publicURI: "http://am3.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
|
||||
@@ -65,6 +65,10 @@ const SilenceDetails = ({
|
||||
u => u.cluster === cluster
|
||||
);
|
||||
|
||||
const isReadOnly =
|
||||
alertStore.data.getClusterAlertmanagersWithoutReadOnly(cluster).length ===
|
||||
0;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="d-flex flex-fill flex-lg-row flex-column justify-content-between">
|
||||
@@ -154,7 +158,8 @@ const SilenceDetails = ({
|
||||
<div className="d-flex flex-fill flex-lg-column flex-row justify-content-around">
|
||||
<button
|
||||
className="btn btn-primary btn-sm mb-lg-2 mb-0"
|
||||
onClick={onEditSilence}
|
||||
disabled={isReadOnly}
|
||||
onClick={!isReadOnly && onEditSilence}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mr-1 d-none d-sm-inline-block"
|
||||
|
||||
@@ -34,6 +34,7 @@ beforeEach(() => {
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
|
||||
@@ -47,6 +47,7 @@ const ManagedSilence = observer(
|
||||
getAlertmanager = () =>
|
||||
this.props.alertStore.data.upstreams.instances
|
||||
.filter(u => u.cluster === this.props.cluster)
|
||||
.filter(u => u.readonly === false)
|
||||
.slice(0, 1)[0];
|
||||
|
||||
onEditSilence = () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ storiesOf("ManagedSilence", module)
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
@@ -38,6 +39,24 @@ storiesOf("ManagedSilence", module)
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
const alertStoreReadOnly = new AlertStore([]);
|
||||
alertStoreReadOnly.data.upstreams = {
|
||||
clusters: { ro: ["readonly"] },
|
||||
instances: [
|
||||
{
|
||||
name: "readonly",
|
||||
uri: "http://localhost:8080",
|
||||
publicURI: "http://example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ro",
|
||||
clusterMembers: ["readonly"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const silence = MockSilence();
|
||||
silence.startsAt = "2018-08-14T16:00:00Z";
|
||||
silence.endsAt = "2018-08-14T18:00:00Z";
|
||||
@@ -67,6 +86,16 @@ storiesOf("ManagedSilence", module)
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster={"ro"}
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={silence}
|
||||
alertStore={alertStoreReadOnly}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster={cluster}
|
||||
alertCount={123}
|
||||
@@ -86,6 +115,16 @@ storiesOf("ManagedSilence", module)
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
<ManagedSilence
|
||||
cluster={"ro"}
|
||||
alertCount={123}
|
||||
alertCountAlwaysVisible={true}
|
||||
silence={expiredSilence}
|
||||
alertStore={alertStoreReadOnly}
|
||||
silenceFormStore={silenceFormStore}
|
||||
onDidUpdate={() => {}}
|
||||
isOpen={true}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ beforeEach(() => {
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
@@ -95,6 +96,7 @@ describe("<ManagedSilence />", () => {
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
|
||||
@@ -30,7 +30,7 @@ const AlertManagerInput = observer(
|
||||
|
||||
if (silenceFormStore.data.alertmanagers.length === 0) {
|
||||
silenceFormStore.data.alertmanagers = AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
alertStore.data.clustersWithoutReadOnly
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ const AlertManagerInput = observer(
|
||||
|
||||
// get the list of last known alertmanagers
|
||||
const currentAlertmanagers = AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
alertStore.data.clustersWithoutReadOnly
|
||||
);
|
||||
|
||||
// now iterate what's set as silence form values and reset it if any
|
||||
@@ -75,7 +75,7 @@ const AlertManagerInput = observer(
|
||||
instanceId="silence-input-alertmanagers"
|
||||
defaultValue={silenceFormStore.data.alertmanagers}
|
||||
options={AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
alertStore.data.clustersWithoutReadOnly
|
||||
)}
|
||||
getOptionValue={JSON.stringify}
|
||||
placeholder={
|
||||
|
||||
@@ -27,6 +27,7 @@ beforeEach(() => {
|
||||
name: "am1",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -37,6 +38,7 @@ beforeEach(() => {
|
||||
name: "am2",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -47,6 +49,7 @@ beforeEach(() => {
|
||||
name: "am3",
|
||||
uri: "http://am3.example.com",
|
||||
publicURI: "http://am3.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -202,4 +205,15 @@ describe("<AlertManagerInput />", () => {
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(0);
|
||||
expect(silenceFormStore.data.alertmanagers).toEqual([]);
|
||||
});
|
||||
|
||||
it("doesn't include readonly instances", () => {
|
||||
alertStore.data.upstreams.instances[0].readonly = true;
|
||||
alertStore.data.upstreams.instances[2].readonly = true;
|
||||
MountedAlertManagerInput();
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(1);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am2",
|
||||
value: ["am2"]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ beforeEach(() => {
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
|
||||
@@ -3,6 +3,9 @@ import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faLock } from "@fortawesome/free-solid-svg-icons/faLock";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import {
|
||||
SilenceFormStore,
|
||||
@@ -16,6 +19,15 @@ import { SilencePreview } from "./SilencePreview";
|
||||
import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController";
|
||||
import { Browser } from "./Browser";
|
||||
|
||||
const ReadOnlyPlaceholder = () => (
|
||||
<div className="jumbotron bg-transparent">
|
||||
<h1 className="display-5 text-placeholder text-center">
|
||||
<FontAwesomeIcon icon={faLock} className="mr-3" />
|
||||
Read only mode
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SilenceModalContent = observer(
|
||||
class SilenceModalContent extends Component {
|
||||
static propTypes = {
|
||||
@@ -82,12 +94,17 @@ const SilenceModalContent = observer(
|
||||
{silenceFormStore.tab.current === SilenceTabNames.Editor ? (
|
||||
silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.UserInput ? (
|
||||
<SilenceForm
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
previewOpen={previewOpen}
|
||||
/>
|
||||
Object.keys(alertStore.data.clustersWithoutReadOnly).length >
|
||||
0 ? (
|
||||
<SilenceForm
|
||||
alertStore={alertStore}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
previewOpen={previewOpen}
|
||||
/>
|
||||
) : (
|
||||
<ReadOnlyPlaceholder />
|
||||
)
|
||||
) : silenceFormStore.data.currentStage ===
|
||||
SilenceFormStage.Preview ? (
|
||||
<SilencePreview
|
||||
|
||||
@@ -20,9 +20,28 @@ beforeEach(() => {
|
||||
settingsStore = new Settings();
|
||||
silenceFormStore = new SilenceFormStore();
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "am",
|
||||
uri: "http://localhost:9093",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
}
|
||||
],
|
||||
clusters: { am: ["am1"] }
|
||||
};
|
||||
|
||||
silenceFormStore.tab.current = SilenceTabNames.Editor;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const MockOnHide = jest.fn();
|
||||
|
||||
const ShallowSilenceModalContent = () => {
|
||||
@@ -38,6 +57,13 @@ const ShallowSilenceModalContent = () => {
|
||||
};
|
||||
|
||||
describe("<SilenceModalContent />", () => {
|
||||
it("Renders ReadOnlyPlaceholder when there are no writable Alertmanager upstreams", () => {
|
||||
alertStore.data.upstreams.instances[0].readonly = true;
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const placeholder = tree.find("ReadOnlyPlaceholder");
|
||||
expect(placeholder).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Clicking on the Browser tab changes content", () => {
|
||||
const tree = ShallowSilenceModalContent();
|
||||
const tabs = tree.find("Tab");
|
||||
|
||||
@@ -95,9 +95,16 @@ const SilenceSubmitProgress = observer(
|
||||
|
||||
const member = this.submitState.membersToTry.pop();
|
||||
|
||||
if (alertStore.data.isReadOnlyAlertmanager(member)) {
|
||||
const err = `Alertmanager instance "${member}" is read-only`;
|
||||
console.error(err);
|
||||
this.maybeTryAgainAfterError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const am = alertStore.data.getAlertmanagerByName(member);
|
||||
if (am === undefined) {
|
||||
const err = `Alertmanager instance "${member} not found`;
|
||||
const err = `Alertmanager instance "${member}" not found`;
|
||||
console.error(err);
|
||||
this.maybeTryAgainAfterError(err);
|
||||
return;
|
||||
|
||||
@@ -15,6 +15,7 @@ beforeEach(() => {
|
||||
name: "mockAlertmanager",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
headers: { foo: "bar" },
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -25,6 +26,10 @@ beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const MountedSilenceSubmitProgress = () => {
|
||||
return mount(
|
||||
<SilenceSubmitProgress
|
||||
@@ -94,6 +99,7 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
name: "am1",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -104,6 +110,7 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
name: "am2",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -150,6 +157,7 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
name: "am1",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
@@ -180,6 +188,66 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("will refuse to send requests to an alertmanager instance that is readonly", async () => {
|
||||
fetch.resetMocks();
|
||||
const logs = [];
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation((message, ...args) => {
|
||||
logs.push(message);
|
||||
});
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
clusters: { ha: ["am1", "am2"] },
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
uri: "http://am1.example.com",
|
||||
publicURI: "http://am1.example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
},
|
||||
{
|
||||
name: "am2",
|
||||
uri: "http://am2.example.com",
|
||||
publicURI: "http://am2.example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const tree = mount(
|
||||
<SilenceSubmitProgress
|
||||
cluster="ha"
|
||||
members={["am1", "am2"]}
|
||||
payload={{
|
||||
matchers: [],
|
||||
startsAt: "now",
|
||||
endsAt: "later",
|
||||
createdBy: "me@example.com",
|
||||
comment: "fake payload"
|
||||
}}
|
||||
alertStore={alertStore}
|
||||
/>
|
||||
);
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
expect(fetch.mock.calls[0][0]).toBe(
|
||||
"http://am1.example.com/api/v1/silences"
|
||||
);
|
||||
expect(logs).toEqual(['Alertmanager instance "am2" is read-only']);
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders returned silence ID on successful fetch", async () => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify({ status: "success", data: { silenceId: "123456789" } })
|
||||
|
||||
@@ -40,6 +40,23 @@ storiesOf("SilenceModal", module)
|
||||
const settingsStore = new Settings();
|
||||
const silenceFormStore = new SilenceFormStore();
|
||||
|
||||
alertStore.data.upstreams = {
|
||||
clusters: { default: ["default"] },
|
||||
instances: [
|
||||
{
|
||||
name: "default",
|
||||
uri: "http://localhost:8080",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["default"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
silenceFormStore.toggle.visible = true;
|
||||
silenceFormStore.data.matchers = [
|
||||
MockMatcher("cluster", ["prod"], false),
|
||||
@@ -96,8 +113,36 @@ storiesOf("SilenceModal", module)
|
||||
}
|
||||
);
|
||||
|
||||
const alertStoreReadOnly = new AlertStore([]);
|
||||
alertStoreReadOnly.data.upstreams = {
|
||||
clusters: { default: ["readonly"] },
|
||||
instances: [
|
||||
{
|
||||
name: "readonly",
|
||||
uri: "http://localhost:8080",
|
||||
publicURI: "http://example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["readonly"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStoreReadOnly}
|
||||
silenceFormStore={silenceFormStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={() => {}}
|
||||
previewOpen={true}
|
||||
onDeleteModalClose={() => {}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal>
|
||||
<SilenceModalContent
|
||||
alertStore={alertStore}
|
||||
@@ -142,6 +187,7 @@ storiesOf("SilenceModal", module)
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
@@ -204,6 +250,7 @@ storiesOf("SilenceModal", module)
|
||||
clusterMembers: ["am1"],
|
||||
uri: "http://localhost:9093",
|
||||
publicURI: "http://example.com",
|
||||
readonly: false,
|
||||
error: "",
|
||||
version: "0.15.3",
|
||||
headers: {}
|
||||
|
||||
@@ -69,6 +69,7 @@ const APIAlertmanagerUpstream = PropTypes.exact({
|
||||
cluster: PropTypes.string.isRequired,
|
||||
uri: PropTypes.string.isRequired,
|
||||
publicURI: PropTypes.string.isRequired,
|
||||
readonly: PropTypes.bool.isRequired,
|
||||
headers: PropTypes.object.isRequired,
|
||||
error: PropTypes.string.isRequired,
|
||||
version: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observable, action, toJS } from "mobx";
|
||||
import { observable, action, computed, toJS } from "mobx";
|
||||
|
||||
import throttle from "lodash/throttle";
|
||||
|
||||
@@ -167,13 +167,37 @@ class AlertStore {
|
||||
getAlertmanagerByName(name) {
|
||||
return this.upstreams.instances.find(am => am.name === name);
|
||||
},
|
||||
isReadOnlyAlertmanager(name) {
|
||||
return this.readOnlyAlertmanagers.map(am => am.name).includes(name);
|
||||
},
|
||||
getClusterAlertmanagersWithoutReadOnly(clusterID) {
|
||||
return this.clustersWithoutReadOnly[clusterID] || [];
|
||||
},
|
||||
get readOnlyAlertmanagers() {
|
||||
return this.upstreams.instances.filter(am => am.readonly === true);
|
||||
},
|
||||
get clustersWithoutReadOnly() {
|
||||
const clusters = {};
|
||||
for (const clusterID of Object.keys(this.upstreams.clusters)) {
|
||||
const members = this.upstreams.clusters[clusterID].filter(
|
||||
member => this.isReadOnlyAlertmanager(member) === false
|
||||
);
|
||||
if (members.length > 0) {
|
||||
clusters[clusterID] = members;
|
||||
}
|
||||
}
|
||||
return clusters;
|
||||
},
|
||||
getColorData(name, value) {
|
||||
if (this.colors[name] !== undefined) {
|
||||
return this.colors[name][value];
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
{
|
||||
readOnlyAlertmanagers: computed,
|
||||
clustersWithoutReadOnly: computed
|
||||
},
|
||||
{ name: "API Response data" }
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,78 @@ afterEach(() => {
|
||||
delete process.env.REACT_APP_BACKEND_URI;
|
||||
});
|
||||
|
||||
describe("AlertStore.data", () => {
|
||||
it("getClusterAlertmanagersWithoutReadOnly filters out readonly instances", () => {
|
||||
const store = new AlertStore([]);
|
||||
store.data.upstreams = {
|
||||
clusters: { default: ["default", "readonly"] },
|
||||
instances: [
|
||||
{
|
||||
name: "default",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://example.com:8080",
|
||||
readonly: false,
|
||||
headers: { foo: "bar" },
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["default", "readonly"]
|
||||
},
|
||||
{
|
||||
name: "readonly",
|
||||
uri: "http://localhost:8081",
|
||||
publicURI: "http://example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["default", "readonly"]
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(
|
||||
store.data.getClusterAlertmanagersWithoutReadOnly("default")
|
||||
).toEqual(["default"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertStore.data", () => {
|
||||
it("getClusterAlertmanagersWithoutReadOnly handles clusters with no writable instances", () => {
|
||||
const store = new AlertStore([]);
|
||||
store.data.upstreams = {
|
||||
clusters: { default: ["ro1", "ro2"] },
|
||||
instances: [
|
||||
{
|
||||
name: "ro1",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://example.com:8080",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["ro1", "ro2"]
|
||||
},
|
||||
{
|
||||
name: "ro2",
|
||||
uri: "http://localhost:8081",
|
||||
publicURI: "http://example.com",
|
||||
readonly: true,
|
||||
headers: {},
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "default",
|
||||
clusterMembers: ["ro1", "ro2"]
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(
|
||||
store.data.getClusterAlertmanagersWithoutReadOnly("default")
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertStore.status", () => {
|
||||
it("status is initially idle with no error", () => {
|
||||
const store = new AlertStore([]);
|
||||
|
||||
@@ -70,6 +70,7 @@ const MockAlertmanager = () => ({
|
||||
cluster: "default",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://am.example.com",
|
||||
readonly: false,
|
||||
headers: {
|
||||
Authorization: "Basic foo bar"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user