OFREP: Enable with standard aggregation (#107349)

This commit is contained in:
Ryan McKinley
2025-07-01 09:17:36 -07:00
committed by GitHub
parent 73e2ead04b
commit 8b9e57f2f6
3 changed files with 250 additions and 25 deletions
+59
View File
@@ -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
}
+138 -25
View File
@@ -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
}
+53
View File
@@ -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))
})
}