diff --git a/pkg/apimachinery/errutil/errors.go b/pkg/apimachinery/errutil/errors.go index e8bc0e4b0f2..2bb0fb79c73 100644 --- a/pkg/apimachinery/errutil/errors.go +++ b/pkg/apimachinery/errutil/errors.go @@ -71,6 +71,17 @@ func UnprocessableEntity(msgID string, opts ...BaseOpt) Base { return NewBase(StatusUnprocessableEntity, msgID, opts...) } +// UnsupportedMediaType initializes a new [Base] error with reason StatusUnsupportedMediaType +// that is used to construct [Error]. The msgID is passed to the caller +// to serve as the base for user facing error messages. +// +// msgID should be structured as component.errorBrief, for example +// +// plugin.unsupportedMediaType +func UnsupportedMediaType(msgID string, opts ...BaseOpt) Base { + return NewBase(StatusUnsupportedMediaType, msgID, opts...) +} + // Conflict initializes a new [Base] error with reason StatusConflict // that is used to construct [Error]. The msgID is passed to the caller // to serve as the base for user facing error messages. diff --git a/pkg/apimachinery/errutil/status.go b/pkg/apimachinery/errutil/status.go index 6379dcdf447..edfede773ba 100644 --- a/pkg/apimachinery/errutil/status.go +++ b/pkg/apimachinery/errutil/status.go @@ -29,6 +29,10 @@ const ( // contained instructions. // HTTP status code 422. StatusUnprocessableEntity CoreStatus = "Unprocessable Entity" + // StatusUnsupportedMediaType means that the server does not support + // the request payload's media type. + // HTTP status code 415. + StatusUnsupportedMediaType CoreStatus = CoreStatus(metav1.StatusReasonUnsupportedMediaType) // StatusConflict means that the server cannot fulfill the request // there is a conflict in the current state of a resource // HTTP status code 409. @@ -107,6 +111,8 @@ func (s CoreStatus) HTTPStatus() int { return http.StatusGatewayTimeout case StatusUnprocessableEntity: return http.StatusUnprocessableEntity + case StatusUnsupportedMediaType: + return http.StatusUnsupportedMediaType case StatusConflict: return http.StatusConflict case StatusTooManyRequests: @@ -137,6 +143,8 @@ func (s CoreStatus) LogLevel() LogLevel { return LevelInfo case StatusTimeout: return LevelInfo + case StatusUnsupportedMediaType: + return LevelInfo case StatusUnprocessableEntity: return LevelInfo case StatusConflict: diff --git a/pkg/services/ngalert/api/prometheus_conversion.go b/pkg/services/ngalert/api/prometheus_conversion.go index d48aa5444b9..9862c862b9a 100644 --- a/pkg/services/ngalert/api/prometheus_conversion.go +++ b/pkg/services/ngalert/api/prometheus_conversion.go @@ -1,15 +1,20 @@ package api import ( + "encoding/json" "io" + "mime" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/errutil" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) +var errorUnsupportedMediaType = errutil.UnsupportedMediaType("alerting.unsupportedMediaType") + type ConvertPrometheusApiHandler struct { svc *ConvertPrometheusSrv } @@ -49,8 +54,30 @@ func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusPostRuleGroup( defer func() { _ = ctx.Req.Body.Close() }() var promGroup apimodels.PrometheusRuleGroup - if err := yaml.Unmarshal(body, &promGroup); err != nil { - return errorToResponse(err) + var m string + + // Parse content-type only if it's not empty, + // otherwise we'll assume it's yaml + contentType := ctx.Req.Header.Get("content-type") + if contentType != "" { + m, _, err = mime.ParseMediaType(contentType) + if err != nil { + return errorToResponse(err) + } + } + + switch m { + case "application/yaml", "": + // mimirtool does not send content-type, so if it's empty, we assume it's yaml + if err := yaml.Unmarshal(body, &promGroup); err != nil { + return errorToResponse(err) + } + case "application/json": + if err := json.Unmarshal(body, &promGroup); err != nil { + return errorToResponse(err) + } + default: + return errorToResponse(errorUnsupportedMediaType.Errorf("unsupported media type: %s, only application/yaml and application/json are supported", m)) } return f.svc.RouteConvertPrometheusPostRuleGroup(ctx, namespaceTitle, promGroup) diff --git a/pkg/tests/api/alerting/api_convert_prometheus_test.go b/pkg/tests/api/alerting/api_convert_prometheus_test.go index e07382e6bca..df25c2ac8f5 100644 --- a/pkg/tests/api/alerting/api_convert_prometheus_test.go +++ b/pkg/tests/api/alerting/api_convert_prometheus_test.go @@ -100,7 +100,7 @@ var ( ) func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { - runTest := func(t *testing.T, enableLokiPaths bool) { + runTest := func(t *testing.T, enableLokiPaths bool, postContentType string) { testinfra.SQLiteIntegrationTest(t) // Setup Grafana and its Database @@ -136,12 +136,16 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS) + postContentTypeHeader := map[string]string{ + "Content-Type": postContentType, + } + t.Run("create rule groups and get them back", func(t *testing.T) { - apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) - apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, postContentTypeHeader) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, postContentTypeHeader) // create a third group in a different namespace - apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, postContentTypeHeader) // And a non-provisioned rule in another namespace namespace3UID := util.GenerateShortUID() @@ -173,11 +177,16 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { requireStatusCode(t, http.StatusForbidden, status, raw) }) + t.Run("with incorrect content-type should receive 415", func(t *testing.T) { + _, status, raw := apiClient.RawConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, map[string]string{"Content-Type": "application/xml"}) + requireStatusCode(t, http.StatusUnsupportedMediaType, status, raw) + }) + t.Run("delete one rule group", func(t *testing.T) { // Create three groups - apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) - apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) - apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, postContentTypeHeader) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, postContentTypeHeader) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, postContentTypeHeader) // delete the first one apiClient.ConvertPrometheusDeleteRuleGroup(t, namespace1, promGroup1.Name, nil) @@ -202,13 +211,51 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { }) } - t.Run("with the mimirtool paths", func(t *testing.T) { - runTest(t, false) - }) + const applicationYAML = "application/yaml" + const applicationJSON = "application/json" - t.Run("with the cortextool Loki paths", func(t *testing.T) { - runTest(t, true) - }) + cases := []struct { + name string + contentType string + enableLokiPaths bool + }{ + { + name: "with the mimirtool paths; empty content-type", + contentType: "", + enableLokiPaths: false, + }, + { + name: "with the cortextool Loki paths; empty content-type", + contentType: "", + enableLokiPaths: true, + }, + { + name: "with the mimirtool paths; yaml", + contentType: applicationYAML, + enableLokiPaths: false, + }, + { + name: "with the cortextool Loki paths; yaml", + contentType: applicationYAML, + enableLokiPaths: true, + }, + { + name: "with the mimirtool paths; json", + contentType: applicationJSON, + enableLokiPaths: false, + }, + { + name: "with the cortextool Loki paths; json", + contentType: applicationJSON, + enableLokiPaths: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runTest(t, tc.enableLokiPaths, tc.contentType) + }) + } } func TestIntegrationConvertPrometheusEndpoints_UpdateRule(t *testing.T) { diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 429f32e755e..f9bb02df433 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -1145,16 +1145,26 @@ func (a apiClient) RawConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitl path = "%s/api/convert/api/prom/rules/%s" } - data, err := yaml.Marshal(promGroup) - require.NoError(t, err) + // Based on the content-type header, marshal the data to JSON or YAML + contentType := headers["Content-Type"] + var data []byte + var err error + if contentType == "application/json" { + data, err = json.Marshal(promGroup) + require.NoError(t, err) + } else { + data, err = yaml.Marshal(promGroup) + require.NoError(t, err) + } + buf := bytes.NewReader(data) req, err := http.NewRequest(http.MethodPost, fmt.Sprintf(path, a.url, namespaceTitle), buf) require.NoError(t, err) - req.Header.Add("X-Grafana-Alerting-Datasource-UID", datasourceUID) + req.Header.Set("X-Grafana-Alerting-Datasource-UID", datasourceUID) for key, value := range headers { - req.Header.Add(key, value) + req.Header.Set(key, value) } return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted)