Alerting: Support JSON Content-Type in the Prometheus conversion API (#102627)

Alerting: Support content-type: json in conversion API
This commit is contained in:
Alexander Akhmetov
2025-03-24 14:15:25 +01:00
committed by GitHub
parent 82ea562b75
commit b4758d06a3
5 changed files with 122 additions and 19 deletions
+11
View File
@@ -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.
+8
View File
@@ -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:
@@ -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)
@@ -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) {
+14 -4
View File
@@ -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)