diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index c1c47dd3b60..78424e19f4b 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -3,7 +3,9 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" + "io" "net/http" "github.com/grafana/grafana/pkg/api/response" @@ -14,13 +16,26 @@ import ( "gopkg.in/yaml.v3" ) +var endpoints = map[string]map[string]string{ + "cortex": { + "silences": "/alertmanager/api/v2/silences", + "silence": "/alertmanager/api/v2/silence/%s", + "status": "/alertmanager/api/v2/status", + "groups": "/alertmanager/api/v2/alerts/groups", + "alerts": "/alertmanager/api/v2/alerts", + "config": "/api/v1/alerts", + }, + "prometheus": { + "silences": "/api/v2/silences", + "silence": "/api/v2/silence/%s", + "status": "/api/v2/status", + "groups": "/api/v2/alerts/groups", + "alerts": "/api/v2/alerts", + }, +} + const ( - amSilencesPath = "/alertmanager/api/v2/silences" - amSilencePath = "/alertmanager/api/v2/silence/%s" - amStatusPath = "/alertmanager/api/v2/status" - amAlertGroupsPath = "/alertmanager/api/v2/alerts/groups" - amAlertsPath = "/alertmanager/api/v2/alerts" - amConfigPath = "/api/v1/alerts" + defaultImplementation = "cortex" ) type LotexAM struct { @@ -35,14 +50,57 @@ func NewLotexAM(proxy *AlertingProxy, log log.Logger) *LotexAM { } } -func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response { +func (am *LotexAM) withAMReq( + ctx *models.ReqContext, + method string, + endpoint string, + pathParams []string, + body io.Reader, + extractor func(*response.NormalResponse) (interface{}, error), + headers map[string]string, +) response.Response { + ds, err := am.DataProxy.DataSourceCache.GetDatasource(ctx.ParamsInt64(":Recipient"), ctx.SignedInUser, ctx.SkipCache) + if err != nil { + if errors.Is(err, models.ErrDataSourceAccessDenied) { + return ErrResp(http.StatusForbidden, err, "Access denied to datasource") + } + if errors.Is(err, models.ErrDataSourceNotFound) { + return ErrResp(http.StatusNotFound, err, "Unable to find datasource") + } + return ErrResp(http.StatusInternalServerError, err, "Unable to load datasource meta data") + } + + impl := ds.JsonData.Get("implementation").MustString(defaultImplementation) + implEndpoints, ok := endpoints[impl] + if !ok { + return ErrResp(http.StatusBadRequest, fmt.Errorf("unsupported Alert Manager implementation \"%s\"", impl), "") + } + endpointPath, ok := implEndpoints[endpoint] + if !ok { + return ErrResp(http.StatusBadRequest, fmt.Errorf("unsupported endpoint \"%s\" for Alert Manager implementation \"%s\"", endpoint, impl), "") + } + + iPathParams := make([]interface{}, len(pathParams)) + for idx, value := range pathParams { + iPathParams[idx] = value + } + return am.withReq( + ctx, + method, + withPath(*ctx.Req.URL, fmt.Sprintf(endpointPath, iPathParams...)), + body, + extractor, + headers, + ) +} + +func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response { + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - amStatusPath, - ), + "status", + nil, nil, jsonExtractor(&apimodels.GettableStatus{}), nil, @@ -54,10 +112,11 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod if err != nil { return ErrResp(500, err, "Failed marshal silence") } - return am.withReq( + return am.withAMReq( ctx, http.MethodPost, - withPath(*ctx.Req.URL, amSilencesPath), + "silences", + nil, bytes.NewBuffer(blob), jsonExtractor(&apimodels.GettableSilence{}), map[string]string{"Content-Type": "application/json"}, @@ -65,13 +124,11 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod } func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodDelete, - withPath( - *ctx.Req.URL, - amConfigPath, - ), + "config", + nil, nil, messageExtractor, nil, @@ -79,13 +136,11 @@ func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Re } func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodDelete, - withPath( - *ctx.Req.URL, - fmt.Sprintf(amSilencePath, macaron.Params(ctx.Req)[":SilenceId"]), - ), + "silence", + []string{macaron.Params(ctx.Req)[":SilenceId"]}, nil, messageExtractor, nil, @@ -93,13 +148,11 @@ func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext) response.Response } func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - amConfigPath, - ), + "config", + nil, nil, yamlExtractor(&apimodels.GettableUserConfig{}), nil, @@ -107,13 +160,11 @@ func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Respo } func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - amAlertGroupsPath, - ), + "groups", + nil, nil, jsonExtractor(&apimodels.AlertGroups{}), nil, @@ -121,13 +172,11 @@ func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Respon } func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - amAlertsPath, - ), + "alerts", + nil, nil, jsonExtractor(&apimodels.GettableAlerts{}), nil, @@ -135,13 +184,11 @@ func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response { } func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - fmt.Sprintf(amSilencePath, macaron.Params(ctx.Req)[":SilenceId"]), - ), + "silence", + []string{macaron.Params(ctx.Req)[":SilenceId"]}, nil, jsonExtractor(&apimodels.GettableSilence{}), nil, @@ -149,13 +196,11 @@ func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext) response.Response { } func (am *LotexAM) RouteGetSilences(ctx *models.ReqContext) response.Response { - return am.withReq( + return am.withAMReq( ctx, http.MethodGet, - withPath( - *ctx.Req.URL, - amSilencesPath, - ), + "silences", + nil, nil, jsonExtractor(&apimodels.GettableSilences{}), nil, @@ -168,10 +213,11 @@ func (am *LotexAM) RoutePostAlertingConfig(ctx *models.ReqContext, config apimod return ErrResp(500, err, "Failed marshal alert manager configuration ") } - return am.withReq( + return am.withAMReq( ctx, http.MethodPost, - withPath(*ctx.Req.URL, amConfigPath), + "config", + nil, bytes.NewBuffer(yml), messageExtractor, nil, @@ -184,10 +230,11 @@ func (am *LotexAM) RoutePostAMAlerts(ctx *models.ReqContext, alerts apimodels.Po return ErrResp(500, err, "Failed marshal postable alerts") } - return am.withReq( + return am.withAMReq( ctx, http.MethodPost, - withPath(*ctx.Req.URL, amAlertsPath), + "alerts", + nil, bytes.NewBuffer(yml), messageExtractor, nil, diff --git a/public/app/features/alerting/unified/Admin.test.tsx b/public/app/features/alerting/unified/Admin.test.tsx index fde78a79634..89926fe9e49 100644 --- a/public/app/features/alerting/unified/Admin.test.tsx +++ b/public/app/features/alerting/unified/Admin.test.tsx @@ -1,7 +1,12 @@ import React from 'react'; import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; import { getAllDataSources } from './utils/config'; -import { fetchAlertManagerConfig, deleteAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager'; +import { + fetchAlertManagerConfig, + deleteAlertManagerConfig, + updateAlertManagerConfig, + fetchStatus, +} from './api/alertmanager'; import { configureStore } from 'app/store/configureStore'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; import Admin from './Admin'; @@ -9,13 +14,17 @@ import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants'; import { render, waitFor } from '@testing-library/react'; -import { byLabelText, byRole } from 'testing-library-selector'; -import { mockDataSource, MockDataSourceSrv } from './mocks'; +import { byLabelText, byRole, byTestId } from 'testing-library-selector'; +import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks'; import { DataSourceType } from './utils/datasource'; import { contextSrv } from 'app/core/services/context_srv'; import store from 'app/core/store'; import userEvent from '@testing-library/user-event'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { + AlertManagerCortexConfig, + AlertManagerDataSourceJsonData, + AlertManagerImplementation, +} from 'app/plugins/datasource/alertmanager/types'; jest.mock('./api/alertmanager'); jest.mock('./api/grafana'); @@ -28,6 +37,7 @@ const mocks = { fetchConfig: typeAsJestMock(fetchAlertManagerConfig), deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig), updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig), + fetchStatus: typeAsJestMock(fetchStatus), }, }; @@ -53,6 +63,13 @@ const dataSources = { name: 'CloudManager', type: DataSourceType.Alertmanager, }), + promAlertManager: mockDataSource({ + name: 'PromManager', + type: DataSourceType.Alertmanager, + jsonData: { + implementation: AlertManagerImplementation.prometheus, + }, + }), }; const ui = { @@ -60,6 +77,7 @@ const ui = { resetButton: byRole('button', { name: /Reset configuration/ }), saveButton: byRole('button', { name: /Save/ }), configInput: byLabelText(/Configuration/), + readOnlyConfig: byTestId('readonly-config'), }; describe('Alerting Admin', () => { @@ -117,4 +135,20 @@ describe('Alerting Admin', () => { await waitFor(() => expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3)); expect(input.value).toEqual(JSON.stringify(newConfig, null, 2)); }); + + it('Read-only when using Prometheus Alertmanager', async () => { + mocks.api.fetchStatus.mockResolvedValue({ + ...someCloudAlertManagerStatus, + config: someCloudAlertManagerConfig.alertmanager_config, + }); + await renderAdminPage(dataSources.promAlertManager.name); + + await ui.readOnlyConfig.find(); + expect(ui.configInput.query()).not.toBeInTheDocument(); + expect(ui.resetButton.query()).not.toBeInTheDocument(); + expect(ui.saveButton.query()).not.toBeInTheDocument(); + + expect(mocks.api.fetchConfig).not.toHaveBeenCalled(); + expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1); + }); }); diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx index 1eb52de6cf1..509674fc167 100644 --- a/public/app/features/alerting/unified/Admin.tsx +++ b/public/app/features/alerting/unified/Admin.tsx @@ -3,7 +3,7 @@ import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } f import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertManagerPicker } from './components/AlertManagerPicker'; -import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from './utils/datasource'; import { useDispatch } from 'react-redux'; import { deleteAlertManagerConfigAction, @@ -23,6 +23,7 @@ export default function Admin(): JSX.Element { const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false); const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig); const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig); + const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false; const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs); @@ -82,41 +83,50 @@ export default function Admin(): JSX.Element {
{({ register, errors }) => ( <> - -