diff --git a/pkg/registry/apis/ofrep/noop.go b/pkg/registry/apis/ofrep/noop.go new file mode 100644 index 00000000000..69d0e7edd6d --- /dev/null +++ b/pkg/registry/apis/ofrep/noop.go @@ -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 +} diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index 344743780e3..b8f2126dbcb 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -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 } diff --git a/pkg/tests/apis/features/features_test.go b/pkg/tests/apis/features/features_test.go new file mode 100644 index 00000000000..1a4b05a898d --- /dev/null +++ b/pkg/tests/apis/features/features_test.go @@ -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)) + }) +}