mirror of https://github.com/grafana/grafana
K8s/FeatureFlags: Add an apiserver to manage feature flags (dev only) (#80501)
* add deployment registry API cloud only * update versions * add feature flag endpoints * use helpers * merge main * update AllowSelfServie and re-run code gen * fix package name * add allowselfserve flag to payload * remove config * update list api to return the full registry including states * change enabled check * fix compile error * add feature toggle and split path in frontend * changes * with status * add more status/state * add back config thing * add back config thing * merge main * merge main * now on the /current api endpoint * now on the /current api endpoint * drop frontend changes * change group name to featuretoggle (singular) * use the same settings * now with patch * more common refs * more common refs * WIP actually do the webhook * fix comment * fewer imports * registe standalone * one less file * fix singular name --------- Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>pull/80780/head
parent
cbc84a802d
commit
41e523bde7
@ -0,0 +1,17 @@ |
||||
package v0alpha1 |
||||
|
||||
// Similar to
|
||||
// https://dev-k8sref-io.web.app/docs/common-definitions/objectreference-/
|
||||
// ObjectReference contains enough information to let you inspect or modify the referred object.
|
||||
type ObjectReference struct { |
||||
Resource string `json:"resource,omitempty"` |
||||
Namespace string `json:"namespace,omitempty"` |
||||
Name string `json:"name,omitempty"` |
||||
|
||||
// APIGroup is the name of the API group that contains the referred object.
|
||||
// The empty string represents the core API group.
|
||||
APIGroup string `json:"apiGroup,omitempty"` |
||||
|
||||
// APIVersion is the version of the API group that contains the referred object.
|
||||
APIVersion string `json:"apiVersion,omitempty"` |
||||
} |
@ -0,0 +1,5 @@ |
||||
// +k8s:deepcopy-gen=package
|
||||
// +k8s:openapi-gen=true
|
||||
// +groupName=featuretoggle.grafana.app
|
||||
|
||||
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
|
@ -0,0 +1,33 @@ |
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
) |
||||
|
||||
const ( |
||||
GROUP = "featuretoggle.grafana.app" |
||||
VERSION = "v0alpha1" |
||||
APIVERSION = GROUP + "/" + VERSION |
||||
) |
||||
|
||||
// FeatureResourceInfo represents each feature that may have a toggle
|
||||
var FeatureResourceInfo = common.NewResourceInfo(GROUP, VERSION, |
||||
"features", "feature", "Feature", |
||||
func() runtime.Object { return &Feature{} }, |
||||
func() runtime.Object { return &FeatureList{} }, |
||||
) |
||||
|
||||
// TogglesResourceInfo represents the actual configuration
|
||||
var TogglesResourceInfo = common.NewResourceInfo(GROUP, VERSION, |
||||
"featuretoggles", "featuretoggle", "FeatureToggles", |
||||
func() runtime.Object { return &FeatureToggles{} }, |
||||
func() runtime.Object { return &FeatureTogglesList{} }, |
||||
) |
||||
|
||||
var ( |
||||
// SchemeGroupVersion is group version used to register these objects
|
||||
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} |
||||
) |
@ -0,0 +1,111 @@ |
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
) |
||||
|
||||
// Feature represents a feature in development and information about that feature
|
||||
// It does *not* know the status, only defines properties about the feature itself
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type Feature struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
Spec FeatureSpec `json:"spec,omitempty"` |
||||
} |
||||
|
||||
type FeatureSpec struct { |
||||
// The feature description
|
||||
Description string `json:"description"` |
||||
|
||||
// Indicates the features level of stability
|
||||
Stage string `json:"stage"` |
||||
|
||||
// The team who owns this feature development
|
||||
Owner string `json:"codeowner,omitempty"` |
||||
|
||||
// Enabled by default for version >=
|
||||
EnabledVersion string `json:"enabledVersion,omitempty"` |
||||
|
||||
// Must be run using in development mode (early dev)
|
||||
RequiresDevMode bool `json:"requiresDevMode,omitempty"` |
||||
|
||||
// The flab behavior only effects frontend -- it is not used in the backend
|
||||
FrontendOnly bool `json:"frontend,omitempty"` |
||||
|
||||
// The flag is used at startup, so any change requires a restart
|
||||
RequiresRestart bool `json:"requiresRestart,omitempty"` |
||||
|
||||
// Allow cloud users to set the values in UI
|
||||
AllowSelfServe bool `json:"allowSelfServe,omitempty"` |
||||
|
||||
// Do not show the value in the UI
|
||||
HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` |
||||
|
||||
// Do not show the value in docs
|
||||
HideFromDocs bool `json:"hideFromDocs,omitempty"` |
||||
} |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FeatureList struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ListMeta `json:"metadata,omitempty"` |
||||
|
||||
Items []Feature `json:"items,omitempty"` |
||||
} |
||||
|
||||
// FeatureToggles define the feature state
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FeatureToggles struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
// The configured toggles. Note this may include unknown fields
|
||||
Spec map[string]bool `json:"spec"` |
||||
} |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FeatureTogglesList struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ListMeta `json:"metadata,omitempty"` |
||||
|
||||
Items []FeatureToggles `json:"items,omitempty"` |
||||
} |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type ResolvedToggleState struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
|
||||
// Can any flag be updated
|
||||
Writeable bool `json:"writeable,omitempty"` |
||||
|
||||
// The currently enabled flags
|
||||
Enabled map[string]bool `json:"enabled,omitempty"` |
||||
|
||||
// Details on the current status
|
||||
Toggles []ToggleStatus `json:"toggles,omitempty"` |
||||
} |
||||
|
||||
type ToggleStatus struct { |
||||
// The feature toggle name
|
||||
Name string `json:"name"` |
||||
|
||||
// The flag description
|
||||
Description string `json:"description,omitempty"` |
||||
|
||||
// Is the flag enabled
|
||||
Enabled bool `json:"enabled"` |
||||
|
||||
// Can this flag be updated
|
||||
Writeable bool `json:"writeable,omitempty"` |
||||
|
||||
// Where was the value configured
|
||||
// eg: startup | tenant|org | user | browser
|
||||
// missing means default
|
||||
Source *common.ObjectReference `json:"source,omitempty"` |
||||
|
||||
// eg: unknown flag
|
||||
Warning string `json:"warning,omitempty"` |
||||
} |
@ -0,0 +1,215 @@ |
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by deepcopy-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
commonv0alpha1 "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
) |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Feature) DeepCopyInto(out *Feature) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
out.Spec = in.Spec |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Feature.
|
||||
func (in *Feature) DeepCopy() *Feature { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(Feature) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Feature) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FeatureList) DeepCopyInto(out *FeatureList) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ListMeta.DeepCopyInto(&out.ListMeta) |
||||
if in.Items != nil { |
||||
in, out := &in.Items, &out.Items |
||||
*out = make([]Feature, len(*in)) |
||||
for i := range *in { |
||||
(*in)[i].DeepCopyInto(&(*out)[i]) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureList.
|
||||
func (in *FeatureList) DeepCopy() *FeatureList { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FeatureList) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FeatureList) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FeatureSpec) DeepCopyInto(out *FeatureSpec) { |
||||
*out = *in |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureSpec.
|
||||
func (in *FeatureSpec) DeepCopy() *FeatureSpec { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FeatureSpec) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FeatureToggles) DeepCopyInto(out *FeatureToggles) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
if in.Spec != nil { |
||||
in, out := &in.Spec, &out.Spec |
||||
*out = make(map[string]bool, len(*in)) |
||||
for key, val := range *in { |
||||
(*out)[key] = val |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureToggles.
|
||||
func (in *FeatureToggles) DeepCopy() *FeatureToggles { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FeatureToggles) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FeatureToggles) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FeatureTogglesList) DeepCopyInto(out *FeatureTogglesList) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ListMeta.DeepCopyInto(&out.ListMeta) |
||||
if in.Items != nil { |
||||
in, out := &in.Items, &out.Items |
||||
*out = make([]FeatureToggles, len(*in)) |
||||
for i := range *in { |
||||
(*in)[i].DeepCopyInto(&(*out)[i]) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTogglesList.
|
||||
func (in *FeatureTogglesList) DeepCopy() *FeatureTogglesList { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FeatureTogglesList) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FeatureTogglesList) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ResolvedToggleState) DeepCopyInto(out *ResolvedToggleState) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
if in.Enabled != nil { |
||||
in, out := &in.Enabled, &out.Enabled |
||||
*out = make(map[string]bool, len(*in)) |
||||
for key, val := range *in { |
||||
(*out)[key] = val |
||||
} |
||||
} |
||||
if in.Toggles != nil { |
||||
in, out := &in.Toggles, &out.Toggles |
||||
*out = make([]ToggleStatus, len(*in)) |
||||
for i := range *in { |
||||
(*in)[i].DeepCopyInto(&(*out)[i]) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedToggleState.
|
||||
func (in *ResolvedToggleState) DeepCopy() *ResolvedToggleState { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(ResolvedToggleState) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *ResolvedToggleState) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ToggleStatus) DeepCopyInto(out *ToggleStatus) { |
||||
*out = *in |
||||
if in.Source != nil { |
||||
in, out := &in.Source, &out.Source |
||||
*out = new(commonv0alpha1.ObjectReference) |
||||
**out = **in |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToggleStatus.
|
||||
func (in *ToggleStatus) DeepCopy() *ToggleStatus { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(ToggleStatus) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
@ -0,0 +1,19 @@ |
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by defaulter-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
) |
||||
|
||||
// RegisterDefaults adds defaulters functions to the given scheme.
|
||||
// Public to allow building arbitrary schemes.
|
||||
// All generated defaulters are covering - they call all nested defaulters.
|
||||
func RegisterDefaults(scheme *runtime.Scheme) error { |
||||
return nil |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,112 @@ |
||||
package featuretoggle |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/web" |
||||
) |
||||
|
||||
func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureManager) v0alpha1.ResolvedToggleState { |
||||
state := v0alpha1.ResolvedToggleState{ |
||||
TypeMeta: v1.TypeMeta{ |
||||
APIVersion: v0alpha1.APIVERSION, |
||||
Kind: "ResolvedToggleState", |
||||
}, |
||||
Enabled: features.GetEnabled(ctx), |
||||
} |
||||
|
||||
// Reference to the object that defined the values
|
||||
startupRef := &common.ObjectReference{ |
||||
Namespace: "system", |
||||
Name: "startup", |
||||
} |
||||
|
||||
startup := features.GetStartupFlags() |
||||
warnings := features.GetWarning() |
||||
for _, f := range features.GetFlags() { |
||||
name := f.Name |
||||
if features.IsHiddenFromAdminPage(name, true) { |
||||
continue |
||||
} |
||||
|
||||
toggle := v0alpha1.ToggleStatus{ |
||||
Name: name, |
||||
Description: f.Description, // simplify the UI changes
|
||||
Enabled: state.Enabled[name], |
||||
Writeable: features.IsEditableFromAdminPage(name), |
||||
Source: startupRef, |
||||
Warning: warnings[name], |
||||
} |
||||
if f.Expression == "true" && toggle.Enabled { |
||||
toggle.Source = nil |
||||
} |
||||
_, inStartup := startup[name] |
||||
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup { |
||||
state.Toggles = append(state.Toggles, toggle) |
||||
} |
||||
} |
||||
return state |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method == http.MethodPatch { |
||||
b.handlePatchCurrent(w, r) |
||||
return |
||||
} |
||||
|
||||
state := getResolvedToggleState(r.Context(), b.features) |
||||
|
||||
err := json.NewEncoder(w).Encode(state) |
||||
if err != nil { |
||||
w.WriteHeader(500) |
||||
} |
||||
} |
||||
|
||||
// NOTE: authz is already handled by the authorizer
|
||||
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { |
||||
if !b.features.IsFeatureEditingAllowed() { |
||||
_, _ = w.Write([]byte("Feature editing is disabled")) |
||||
return |
||||
} |
||||
|
||||
ctx := r.Context() |
||||
request := v0alpha1.ResolvedToggleState{} |
||||
err := web.Bind(r, &request) |
||||
if err != nil { |
||||
_, _ = w.Write([]byte("ERROR!!! " + err.Error())) |
||||
return |
||||
} |
||||
|
||||
if len(request.Toggles) > 0 { |
||||
w.WriteHeader(http.StatusBadRequest) |
||||
_, _ = w.Write([]byte("can only patch the enabled")) |
||||
return |
||||
} |
||||
|
||||
changes := map[string]bool{} |
||||
for k, v := range request.Enabled { |
||||
current := b.features.IsEnabled(ctx, k) |
||||
if current != v { |
||||
if !b.features.IsEditableFromAdminPage(k) { |
||||
w.WriteHeader(http.StatusBadRequest) |
||||
_, _ = w.Write([]byte("can not edit toggle: " + k)) |
||||
return |
||||
} |
||||
changes[k] = v |
||||
} |
||||
} |
||||
|
||||
if len(changes) == 0 { |
||||
w.WriteHeader(http.StatusNotModified) |
||||
return |
||||
} |
||||
|
||||
_, _ = w.Write([]byte("TODO... actually UPDATE/call webhook: ")) |
||||
} |
@ -0,0 +1,125 @@ |
||||
package featuretoggle |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" |
||||
) |
||||
|
||||
var ( |
||||
_ rest.Storage = (*featuresStorage)(nil) |
||||
_ rest.Scoper = (*featuresStorage)(nil) |
||||
_ rest.SingularNameProvider = (*featuresStorage)(nil) |
||||
_ rest.Lister = (*featuresStorage)(nil) |
||||
_ rest.Getter = (*featuresStorage)(nil) |
||||
) |
||||
|
||||
type featuresStorage struct { |
||||
resource *common.ResourceInfo |
||||
tableConverter rest.TableConvertor |
||||
features []featuremgmt.FeatureFlag |
||||
startup int64 |
||||
} |
||||
|
||||
// NOTE! this does not depend on config or any system state!
|
||||
// In the future, the existence of features (and their properties) can be defined dynamically
|
||||
func NewFeaturesStorage(features []featuremgmt.FeatureFlag) *featuresStorage { |
||||
resourceInfo := v0alpha1.FeatureResourceInfo |
||||
return &featuresStorage{ |
||||
startup: time.Now().UnixMilli(), |
||||
resource: &resourceInfo, |
||||
features: features, |
||||
tableConverter: utils.NewTableConverter( |
||||
resourceInfo.GroupResource(), |
||||
[]metav1.TableColumnDefinition{ |
||||
{Name: "Name", Type: "string", Format: "name"}, |
||||
{Name: "Stage", Type: "string", Format: "string", Description: "Where is the flag in the dev cycle"}, |
||||
{Name: "Owner", Type: "string", Format: "string", Description: "Which team owns the feature"}, |
||||
}, |
||||
func(obj any) ([]interface{}, error) { |
||||
r, ok := obj.(*v0alpha1.Feature) |
||||
if ok { |
||||
return []interface{}{ |
||||
r.Name, |
||||
r.Spec.Stage, |
||||
r.Spec.Owner, |
||||
}, nil |
||||
} |
||||
return nil, fmt.Errorf("expected resource or info") |
||||
}), |
||||
} |
||||
} |
||||
|
||||
func (s *featuresStorage) New() runtime.Object { |
||||
return s.resource.NewFunc() |
||||
} |
||||
|
||||
func (s *featuresStorage) Destroy() {} |
||||
|
||||
func (s *featuresStorage) NamespaceScoped() bool { |
||||
return false |
||||
} |
||||
|
||||
func (s *featuresStorage) GetSingularName() string { |
||||
return s.resource.GetSingularName() |
||||
} |
||||
|
||||
func (s *featuresStorage) NewList() runtime.Object { |
||||
return s.resource.NewListFunc() |
||||
} |
||||
|
||||
func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { |
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions) |
||||
} |
||||
|
||||
func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { |
||||
flags := &v0alpha1.FeatureList{ |
||||
ListMeta: metav1.ListMeta{ |
||||
ResourceVersion: fmt.Sprintf("%d", s.startup), |
||||
}, |
||||
} |
||||
for _, flag := range s.features { |
||||
flags.Items = append(flags.Items, toK8sForm(flag)) |
||||
} |
||||
return flags, nil |
||||
} |
||||
|
||||
func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { |
||||
for _, flag := range s.features { |
||||
if name == flag.Name { |
||||
obj := toK8sForm(flag) |
||||
return &obj, nil |
||||
} |
||||
} |
||||
return nil, fmt.Errorf("not found") |
||||
} |
||||
|
||||
func toK8sForm(flag featuremgmt.FeatureFlag) v0alpha1.Feature { |
||||
return v0alpha1.Feature{ |
||||
ObjectMeta: metav1.ObjectMeta{ |
||||
Name: flag.Name, |
||||
CreationTimestamp: metav1.NewTime(flag.Created), |
||||
}, |
||||
Spec: v0alpha1.FeatureSpec{ |
||||
Description: flag.Description, |
||||
Stage: flag.Stage.String(), |
||||
Owner: string(flag.Owner), |
||||
AllowSelfServe: flag.AllowSelfServe, |
||||
HideFromAdminPage: flag.HideFromAdminPage, |
||||
HideFromDocs: flag.HideFromDocs, |
||||
FrontendOnly: flag.FrontendOnly, |
||||
RequiresDevMode: flag.RequiresDevMode, |
||||
RequiresRestart: flag.RequiresRestart, |
||||
}, |
||||
} |
||||
} |
@ -0,0 +1,209 @@ |
||||
package featuretoggle |
||||
|
||||
import ( |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
"k8s.io/apimachinery/pkg/runtime/serializer" |
||||
"k8s.io/apiserver/pkg/authorization/authorizer" |
||||
"k8s.io/apiserver/pkg/registry/generic" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
genericapiserver "k8s.io/apiserver/pkg/server" |
||||
common "k8s.io/kube-openapi/pkg/common" |
||||
"k8s.io/kube-openapi/pkg/spec3" |
||||
"k8s.io/kube-openapi/pkg/validation/spec" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" |
||||
) |
||||
|
||||
var _ grafanaapiserver.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) |
||||
|
||||
var gv = v0alpha1.SchemeGroupVersion |
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type FeatureFlagAPIBuilder struct { |
||||
features *featuremgmt.FeatureManager |
||||
} |
||||
|
||||
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder { |
||||
return &FeatureFlagAPIBuilder{features} |
||||
} |
||||
|
||||
func RegisterAPIService(features *featuremgmt.FeatureManager, |
||||
apiregistration grafanaapiserver.APIRegistrar, |
||||
) *FeatureFlagAPIBuilder { |
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { |
||||
return nil // skip registration unless opting into experimental apis
|
||||
} |
||||
builder := NewFeatureFlagAPIBuilder(features) |
||||
apiregistration.RegisterAPI(builder) |
||||
return builder |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion { |
||||
return gv |
||||
} |
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { |
||||
scheme.AddKnownTypes(gv, |
||||
&v0alpha1.Feature{}, |
||||
&v0alpha1.FeatureList{}, |
||||
&v0alpha1.FeatureToggles{}, |
||||
&v0alpha1.FeatureTogglesList{}, |
||||
&v0alpha1.ResolvedToggleState{}, |
||||
) |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { |
||||
addKnownTypes(scheme, gv) |
||||
|
||||
// Link this version to the internal representation.
|
||||
// This is used for server-side-apply (PATCH), and avoids the error:
|
||||
// "no kind is registered for the type"
|
||||
addKnownTypes(scheme, schema.GroupVersion{ |
||||
Group: gv.Group, |
||||
Version: runtime.APIVersionInternal, |
||||
}) |
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv) |
||||
return scheme.SetVersionPriority(gv) |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo( |
||||
scheme *runtime.Scheme, |
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter, |
||||
) (*genericapiserver.APIGroupInfo, error) { |
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) |
||||
|
||||
featureStore := NewFeaturesStorage(b.features.GetFlags()) |
||||
toggleStore := NewTogglesStorage(b.features) |
||||
|
||||
storage := map[string]rest.Storage{} |
||||
storage[featureStore.resource.StoragePath()] = featureStore |
||||
storage[toggleStore.resource.StoragePath()] = toggleStore |
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage |
||||
return &apiGroupInfo, nil |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { |
||||
return v0alpha1.GetOpenAPIDefinitions |
||||
} |
||||
|
||||
func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer { |
||||
return nil // default authorizer is fine
|
||||
} |
||||
|
||||
// Register additional routes with the server
|
||||
func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { |
||||
defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) |
||||
stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema |
||||
|
||||
tags := []string{"Editor"} |
||||
return &grafanaapiserver.APIRoutes{ |
||||
Root: []grafanaapiserver.APIRouteHandler{ |
||||
{ |
||||
Path: "current", |
||||
Spec: &spec3.PathProps{ |
||||
Get: &spec3.Operation{ |
||||
OperationProps: spec3.OperationProps{ |
||||
Tags: tags, |
||||
Summary: "Current configuration with details", |
||||
Description: "Show details about the current flags and where they come from", |
||||
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: &stateSchema, |
||||
}, |
||||
}, |
||||
}, |
||||
Description: "OK", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Patch: &spec3.Operation{ |
||||
OperationProps: spec3.OperationProps{ |
||||
Tags: tags, |
||||
Summary: "Update individual toggles", |
||||
Description: "Patch some of the toggles (keyed by the toggle name)", |
||||
RequestBody: &spec3.RequestBody{ |
||||
RequestBodyProps: spec3.RequestBodyProps{ |
||||
Required: true, |
||||
Description: "flags to change", |
||||
Content: map[string]*spec3.MediaType{ |
||||
"application/json": { |
||||
MediaTypeProps: spec3.MediaTypeProps{ |
||||
Schema: &stateSchema, |
||||
Example: &v0alpha1.ResolvedToggleState{ |
||||
Enabled: map[string]bool{ |
||||
featuremgmt.FlagAutoMigrateOldPanels: true, |
||||
featuremgmt.FlagAngularDeprecationUI: false, |
||||
}, |
||||
}, |
||||
Examples: map[string]*spec3.Example{ |
||||
"enable-auto-migrate": { |
||||
ExampleProps: spec3.ExampleProps{ |
||||
Summary: "enable auto-migrate panels", |
||||
Description: "example descr", |
||||
Value: &v0alpha1.ResolvedToggleState{ |
||||
Enabled: map[string]bool{ |
||||
featuremgmt.FlagAutoMigrateOldPanels: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
"disable-auto-migrate": { |
||||
ExampleProps: spec3.ExampleProps{ |
||||
Summary: "disable auto-migrate panels", |
||||
Description: "disable descr", |
||||
Value: &v0alpha1.ResolvedToggleState{ |
||||
Enabled: map[string]bool{ |
||||
featuremgmt.FlagAutoMigrateOldPanels: false, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Responses: &spec3.Responses{ |
||||
ResponsesProps: spec3.ResponsesProps{ |
||||
StatusCodeResponses: map[int]*spec3.Response{ |
||||
200: { |
||||
ResponseProps: spec3.ResponseProps{ |
||||
Content: map[string]*spec3.MediaType{ |
||||
"application/json": {}, |
||||
}, |
||||
Description: "OK", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Handler: b.handleCurrentStatus, |
||||
}, |
||||
}, |
||||
} |
||||
} |
@ -0,0 +1,92 @@ |
||||
package featuretoggle |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
) |
||||
|
||||
var ( |
||||
_ rest.Storage = (*togglesStorage)(nil) |
||||
_ rest.Scoper = (*togglesStorage)(nil) |
||||
_ rest.SingularNameProvider = (*togglesStorage)(nil) |
||||
_ rest.Lister = (*togglesStorage)(nil) |
||||
_ rest.Getter = (*togglesStorage)(nil) |
||||
) |
||||
|
||||
type togglesStorage struct { |
||||
resource *common.ResourceInfo |
||||
tableConverter rest.TableConvertor |
||||
|
||||
// The startup toggles
|
||||
startup *v0alpha1.FeatureToggles |
||||
} |
||||
|
||||
func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage { |
||||
resourceInfo := v0alpha1.TogglesResourceInfo |
||||
return &togglesStorage{ |
||||
resource: &resourceInfo, |
||||
startup: &v0alpha1.FeatureToggles{ |
||||
TypeMeta: resourceInfo.TypeMeta(), |
||||
ObjectMeta: metav1.ObjectMeta{ |
||||
Name: "startup", |
||||
Namespace: "system", |
||||
CreationTimestamp: metav1.Now(), |
||||
}, |
||||
Spec: features.GetStartupFlags(), |
||||
}, |
||||
tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), |
||||
} |
||||
} |
||||
|
||||
func (s *togglesStorage) New() runtime.Object { |
||||
return s.resource.NewFunc() |
||||
} |
||||
|
||||
func (s *togglesStorage) Destroy() {} |
||||
|
||||
func (s *togglesStorage) NamespaceScoped() bool { |
||||
return true |
||||
} |
||||
|
||||
func (s *togglesStorage) GetSingularName() string { |
||||
return s.resource.GetSingularName() |
||||
} |
||||
|
||||
func (s *togglesStorage) NewList() runtime.Object { |
||||
return s.resource.NewListFunc() |
||||
} |
||||
|
||||
func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { |
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions) |
||||
} |
||||
|
||||
func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { |
||||
flags := &v0alpha1.FeatureTogglesList{ |
||||
Items: []v0alpha1.FeatureToggles{*s.startup}, |
||||
} |
||||
return flags, nil |
||||
} |
||||
|
||||
func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { |
||||
info, err := request.NamespaceInfoFrom(ctx, false) // allow system
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if info.Value != "" && info.Value != "system" { |
||||
return nil, fmt.Errorf("only system namespace is currently supported") |
||||
} |
||||
if name != "startup" { |
||||
return nil, fmt.Errorf("only system/startup is currently supported") |
||||
} |
||||
return s.startup, nil |
||||
} |
|
Loading…
Reference in new issue