Alerting: Support JSON Content-Type in the Prometheus conversion API (#102627)
Alerting: Support content-type: json in conversion API
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user