Merge pull request #1380 from prymitive/ro

feat(ui): disable silence actions for read-only alertmanagers
This commit is contained in:
Łukasz Mierzwa
2020-01-28 22:51:00 +00:00
committed by GitHub
38 changed files with 544 additions and 28 deletions
+13 -4
View File
@@ -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
+1
View File
@@ -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(),
+5
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
+11
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
+1
View File
@@ -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
+10 -1
View File
@@ -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 {
+88
View File
@@ -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)
}
}
}
+8 -4
View File
@@ -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)
+1
View File
@@ -63,6 +63,7 @@ func testReadConfig(t *testing.T) {
external_uri: http://example.com
timeout: 40s
proxy: false
readonly: false
tls:
ca: ""
cert: ""
+3 -2
View File
@@ -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
+1
View File
@@ -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"`
+1 -1
View File
@@ -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))
);
+6
View File
@@ -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: {}
+1
View File
@@ -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,
+26 -2
View File
@@ -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" }
);
+72
View File
@@ -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([]);
+1
View File
@@ -70,6 +70,7 @@ const MockAlertmanager = () => ({
cluster: "default",
uri: "http://localhost",
publicURI: "http://am.example.com",
readonly: false,
headers: {
Authorization: "Basic foo bar"
},