diff --git a/README.md b/README.md index 6814961ac..6c654d3bb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/karma/alerts.go b/cmd/karma/alerts.go index 2d08528e8..1f797c7ea 100644 --- a/cmd/karma/alerts.go +++ b/cmd/karma/alerts.go @@ -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(), diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 2dad1a294..77a839004 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -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), ) diff --git a/cmd/karma/testdata/proxy-with-readonly.txt b/cmd/karma/testdata/proxy-with-readonly.txt new file mode 100644 index 000000000..152b104b8 --- /dev/null +++ b/cmd/karma/testdata/proxy-with-readonly.txt @@ -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 diff --git a/cmd/karma/testdata/proxy.txt b/cmd/karma/testdata/proxy.txt new file mode 100644 index 000000000..1857fc00d --- /dev/null +++ b/cmd/karma/testdata/proxy.txt @@ -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 diff --git a/cmd/karma/testdata/readonly.txt b/cmd/karma/testdata/readonly.txt new file mode 100644 index 000000000..312d104fe --- /dev/null +++ b/cmd/karma/testdata/readonly.txt @@ -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 diff --git a/cmd/karma/testdata/simple_config.txt b/cmd/karma/testdata/simple_config.txt index 144dc4870..9f05889c0 100644 --- a/cmd/karma/testdata/simple_config.txt +++ b/cmd/karma/testdata/simple_config.txt @@ -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\)"' diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a7e51592a..59187f3a3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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: diff --git a/docs/example.yaml b/docs/example.yaml index 86d89936e..bd1e8e94b 100644 --- a/docs/example.yaml +++ b/docs/example.yaml @@ -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 diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index b9b2db984..8126607e3 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -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 diff --git a/internal/alertmanager/upstream.go b/internal/alertmanager/upstream.go index b877be0e6..5ac04afd6 100644 --- a/internal/alertmanager/upstream.go +++ b/internal/alertmanager/upstream.go @@ -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 { diff --git a/internal/alertmanager/upstream_test.go b/internal/alertmanager/upstream_test.go new file mode 100644 index 000000000..0fea9359c --- /dev/null +++ b/internal/alertmanager/upstream_test.go @@ -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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 03339c8ef..b12fd1cab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5f2fe2de..cdbe818a4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -63,6 +63,7 @@ func testReadConfig(t *testing.T) { external_uri: http://example.com timeout: 40s proxy: false + readonly: false tls: ca: "" cert: "" diff --git a/internal/config/models.go b/internal/config/models.go index caab853ab..e7ffdf6fb 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -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 diff --git a/internal/models/alertmanager.go b/internal/models/alertmanager.go index e25d40e2b..00f9e69f5 100644 --- a/internal/models/alertmanager.go +++ b/internal/models/alertmanager.go @@ -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"` diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 17a1fbd82..4f6839bc5 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -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)) ); diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index 9512f7dd8..98b33af45 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -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("", () => { 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("", () => { 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("", () => { 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("", () => { 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("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.js b/ui/src/Components/ManagedSilence/DeleteSilence.js index 20589f1db..ecac4df05 100644 --- a/ui/src/Components/ManagedSilence/DeleteSilence.js +++ b/ui/src/Components/ManagedSilence/DeleteSilence.js @@ -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 ( { name: "am1", cluster: "am", uri: "http://localhost:9093", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/ManagedSilence/SilenceComment.test.js b/ui/src/Components/ManagedSilence/SilenceComment.test.js index 85163e669..f1f575a0e 100644 --- a/ui/src/Components/ManagedSilence/SilenceComment.test.js +++ b/ui/src/Components/ManagedSilence/SilenceComment.test.js @@ -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", diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.js b/ui/src/Components/ManagedSilence/SilenceDetails.js index c5e33f236..68a9fffa6 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.js +++ b/ui/src/Components/ManagedSilence/SilenceDetails.js @@ -65,6 +65,10 @@ const SilenceDetails = ({ u => u.cluster === cluster ); + const isReadOnly = + alertStore.data.getClusterAlertmanagersWithoutReadOnly(cluster).length === + 0; + return ( @@ -154,7 +158,8 @@ const SilenceDetails = ({ { cluster: "am", uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/ManagedSilence/index.js b/ui/src/Components/ManagedSilence/index.js index a5c65f346..43bce27cf 100644 --- a/ui/src/Components/ManagedSilence/index.js +++ b/ui/src/Components/ManagedSilence/index.js @@ -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 = () => { diff --git a/ui/src/Components/ManagedSilence/index.stories.js b/ui/src/Components/ManagedSilence/index.stories.js index 225019b47..fdbfb9f7e 100644 --- a/ui/src/Components/ManagedSilence/index.stories.js +++ b/ui/src/Components/ManagedSilence/index.stories.js @@ -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} /> + {}} + isOpen={true} + /> {}} isOpen={true} /> + {}} + isOpen={true} + /> ); }); diff --git a/ui/src/Components/ManagedSilence/index.test.js b/ui/src/Components/ManagedSilence/index.test.js index 63dc58d00..790f0f29e 100644 --- a/ui/src/Components/ManagedSilence/index.test.js +++ b/ui/src/Components/ManagedSilence/index.test.js @@ -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("", () => { clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.js index 0af26a784..145e96dcc 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.js @@ -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={ diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js index 21927decb..a6cf97797 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js @@ -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("", () => { 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"] + }); + }); }); diff --git a/ui/src/Components/SilenceModal/Browser/index.test.js b/ui/src/Components/SilenceModal/Browser/index.test.js index 2c0642fdc..006616411 100644 --- a/ui/src/Components/SilenceModal/Browser/index.test.js +++ b/ui/src/Components/SilenceModal/Browser/index.test.js @@ -36,6 +36,7 @@ beforeEach(() => { clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js index cf7efad8e..fa0c63ef6 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.js @@ -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 = () => ( + + + + Read only mode + + +); + 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 ? ( - + Object.keys(alertStore.data.clustersWithoutReadOnly).length > + 0 ? ( + + ) : ( + + ) ) : silenceFormStore.data.currentStage === SilenceFormStage.Preview ? ( { 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("", () => { + 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"); diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 56f047992..8e094b666 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -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; diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index 4fe66a414..8af0ca694 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -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( ", () => { 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("", () => { 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("", () => { 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("", () => { 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( + + ); + 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" } }) diff --git a/ui/src/Components/SilenceModal/index.stories.js b/ui/src/Components/SilenceModal/index.stories.js index ca59f129e..7afee41d6 100644 --- a/ui/src/Components/SilenceModal/index.stories.js +++ b/ui/src/Components/SilenceModal/index.stories.js @@ -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 ( + + {}} + previewOpen={true} + onDeleteModalClose={() => {}} + /> + 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" } ); diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index 6b95b35d9..98c5e67c7 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -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([]); diff --git a/ui/src/__mocks__/Alerts.js b/ui/src/__mocks__/Alerts.js index d09fce329..9b6225f18 100644 --- a/ui/src/__mocks__/Alerts.js +++ b/ui/src/__mocks__/Alerts.js @@ -70,6 +70,7 @@ const MockAlertmanager = () => ({ cluster: "default", uri: "http://localhost", publicURI: "http://am.example.com", + readonly: false, headers: { Authorization: "Basic foo bar" },