OFREP: Enable with standard aggregation (#107349)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package ofrep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
)
|
||||
|
||||
// This is a dummy connector that is not actually used for anything
|
||||
// EXCEPT -- k8s requires *something* to be registered so it will add the storage to discovery.
|
||||
// As a quick workaround, we register a noop storage; and then manually remove it from openapi
|
||||
type NoopConnector struct{}
|
||||
|
||||
var (
|
||||
_ rest.Connecter = (*NoopConnector)(nil)
|
||||
_ rest.StorageMetadata = (*NoopConnector)(nil)
|
||||
_ rest.Scoper = (*NoopConnector)(nil)
|
||||
_ rest.SingularNameProvider = (*NoopConnector)(nil)
|
||||
)
|
||||
|
||||
func (r *NoopConnector) New() runtime.Object {
|
||||
return &metav1.Status{}
|
||||
}
|
||||
|
||||
func (r *NoopConnector) NamespaceScoped() bool {
|
||||
return true // namespaced
|
||||
}
|
||||
|
||||
func (r *NoopConnector) GetSingularName() string {
|
||||
return "noop"
|
||||
}
|
||||
|
||||
func (r *NoopConnector) Destroy() {
|
||||
}
|
||||
|
||||
func (r *NoopConnector) ConnectMethods() []string {
|
||||
return []string{"GET"}
|
||||
}
|
||||
|
||||
func (r *NoopConnector) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (r *NoopConnector) ProducesMIMETypes(verb string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NoopConnector) ProducesObject(verb string) interface{} {
|
||||
return r.New()
|
||||
}
|
||||
|
||||
func (r *NoopConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
_, _ = w.Write([]byte("NOOP"))
|
||||
}), nil
|
||||
}
|
||||
@@ -7,23 +7,23 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/gorilla/mux"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
@@ -32,6 +32,11 @@ var _ builder.APIGroupVersionProvider = (*APIBuilder)(nil)
|
||||
|
||||
const ofrepPath = "/ofrep/v1/evaluate/flags"
|
||||
|
||||
var groupVersion = schema.GroupVersion{
|
||||
Group: "features.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
}
|
||||
|
||||
type APIBuilder struct {
|
||||
providerType string
|
||||
url *url.URL
|
||||
@@ -66,18 +71,19 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return schema.GroupVersion{
|
||||
Group: "features.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
}
|
||||
return groupVersion
|
||||
}
|
||||
|
||||
func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
metav1.AddToGroupVersion(scheme, b.GetGroupVersion())
|
||||
return scheme.SetVersionPriority(b.GetGroupVersion())
|
||||
metav1.AddToGroupVersion(scheme, groupVersion)
|
||||
scheme.AddKnownTypes(groupVersion, &metav1.Status{}) // for noop
|
||||
return scheme.SetVersionPriority(groupVersion)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storage := map[string]rest.Storage{}
|
||||
storage["noop"] = &NoopConnector{}
|
||||
apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,20 +97,125 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
return []string{builder.AllResourcesAllowed}
|
||||
}
|
||||
|
||||
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
oas.Info.Description = "Proxy access to open feature flags"
|
||||
|
||||
// Remove the NOOP connector
|
||||
delete(oas.Paths.Paths, "/apis/"+groupVersion.String()+"/namespaces/{namespace}/noop/{name}")
|
||||
return oas, nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
evaluationContext := &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
Description: "EvaluationContext provides ambient information for the purposes of flag evaluation",
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: spec.MapProperty(spec.MapProperty(nil)),
|
||||
Example: map[string]map[string]any{
|
||||
"context": {
|
||||
"targetingKey": "1234",
|
||||
"grafana_version": "12.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: "ofrep/v1/evaluate/flags/",
|
||||
Spec: &spec3.PathProps{
|
||||
Post: &spec3.Operation{},
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: []string{"Evaluate"},
|
||||
Description: "Evaluate all flags",
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestBody: evaluationContext,
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: spec.MapProperty(nil), // TODO... real type?
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: b.allFlagsHandler,
|
||||
},
|
||||
{
|
||||
Path: "ofrep/v1/evaluate/flags/{flagKey}",
|
||||
Spec: &spec3.PathProps{
|
||||
Post: &spec3.Operation{},
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: []string{"Evaluate"},
|
||||
Description: "Evaluate a single flag",
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "flagKey",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "testflag",
|
||||
Description: "flag key",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestBody: evaluationContext,
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: spec.MapProperty(nil), // TODO, real type
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: b.oneFlagHandler,
|
||||
},
|
||||
@@ -168,29 +279,25 @@ func writeResponse(statusCode int, result any, logger log.Logger, w http.Respons
|
||||
}
|
||||
}
|
||||
|
||||
func (b *APIBuilder) stackIdFromEvalCtx(body []byte) string {
|
||||
func (b *APIBuilder) stackIdFromEvalCtx(body []byte) int64 {
|
||||
// Extract stackID from request body without consuming it
|
||||
var evalCtx struct {
|
||||
Context struct {
|
||||
StackID int32 `json:"stackId"`
|
||||
StackID int64 `json:"stackId"` // TODO -- replace with namespace "stackId" ONLY makes sense in cloud
|
||||
} `json:"context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &evalCtx); err != nil {
|
||||
b.logger.Debug("Failed to unmarshal evaluation context", "error", err, "body", string(body))
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
|
||||
if evalCtx.Context.StackID <= 0 {
|
||||
b.logger.Debug("Invalid or missing stackId in evaluation context", "stackId", evalCtx.Context.StackID)
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
|
||||
return strconv.Itoa(int(evalCtx.Context.StackID))
|
||||
}
|
||||
|
||||
func removeStackPrefix(tenant string) string {
|
||||
return strings.TrimPrefix(tenant, "stacks-")
|
||||
return evalCtx.Context.StackID
|
||||
}
|
||||
|
||||
// isAuthenticatedRequest returns true if the request is authenticated
|
||||
@@ -217,6 +324,12 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
|
||||
namespace = mux.Vars(r)["namespace"]
|
||||
}
|
||||
|
||||
info, err := types.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
b.logger.Error("Error parsing namespace", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract stackId from feature flag evaluation context
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -226,7 +339,7 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
// "default" namespace case can only occur in on-prem grafana
|
||||
if b.stackIdFromEvalCtx(body) == removeStackPrefix(namespace) || namespace == "default" {
|
||||
if b.stackIdFromEvalCtx(body) == info.StackID {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package features
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func TestIntegrationFeatures(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// Enable a random flag -- check that it is reported as enabled
|
||||
flag := featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: false, // allow anon user
|
||||
EnableFeatureToggles: []string{
|
||||
flag, // used in test below
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("Test evaluate flags", func(t *testing.T) {
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
Method: http.MethodPost,
|
||||
Path: "/apis/features.grafana.app/v0alpha1/namespaces/default/ofrep/v1/evaluate/flags/" + flag,
|
||||
User: helper.Org1.Admin,
|
||||
}, &map[string]any{})
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
require.JSONEq(t, `{
|
||||
"Value": true,
|
||||
"FlagKey": "`+flag+`",
|
||||
"FlagType": 0,
|
||||
"Variant": "enabled",
|
||||
"Reason": "STATIC",
|
||||
"ErrorCode": "",
|
||||
"ErrorMessage": "",
|
||||
"FlagMetadata": {}
|
||||
}`, string(rsp.Body))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user