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
Ryan McKinley 2 years ago committed by GitHub
parent cbc84a802d
commit 41e523bde7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 2
      hack/README.md
  3. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 42
      pkg/api/featuremgmt.go
  5. 7
      pkg/api/featuremgmt_test.go
  6. 17
      pkg/apis/common/v0alpha1/types.go
  7. 152
      pkg/apis/common/v0alpha1/zz_generated.openapi.go
  8. 5
      pkg/apis/featuretoggle/v0alpha1/doc.go
  9. 33
      pkg/apis/featuretoggle/v0alpha1/register.go
  10. 111
      pkg/apis/featuretoggle/v0alpha1/types.go
  11. 215
      pkg/apis/featuretoggle/v0alpha1/zz_generated.deepcopy.go
  12. 19
      pkg/apis/featuretoggle/v0alpha1/zz_generated.defaults.go
  13. 2899
      pkg/apis/featuretoggle/v0alpha1/zz_generated.openapi.go
  14. 10
      pkg/cmd/grafana/apiserver/server.go
  15. 2
      pkg/registry/apis/apis.go
  16. 112
      pkg/registry/apis/featuretoggle/current.go
  17. 125
      pkg/registry/apis/featuretoggle/features.go
  18. 209
      pkg/registry/apis/featuretoggle/register.go
  19. 92
      pkg/registry/apis/featuretoggle/toggles.go
  20. 2
      pkg/registry/apis/wireset.go
  21. 89
      pkg/services/featuremgmt/manager.go
  22. 10
      pkg/services/featuremgmt/registry.go
  23. 16
      pkg/services/featuremgmt/service.go
  24. 1
      pkg/services/featuremgmt/toggles_gen.csv
  25. 4
      pkg/services/featuremgmt/toggles_gen.go

@ -170,6 +170,7 @@ Experimental features might be changed or removed without prior notice.
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
| `enablePluginsTracingByDefault` | Enable plugin tracing for all external plugins |
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |

@ -14,6 +14,8 @@ total 0
lrwxr-xr-x 1 ryan staff 37 Oct 5 09:34 grafana -> /Users/ryan/workspace/grafana/grafana
```
You can clone k8s [code-generator](https://github.com/kubernetes/code-generator) here and use `CODEGEN_PKG=<CODE-GENERATOR-GIT-ROOT>` when running the `update-codegen.sh` script.
The current workflow (sorry!) is to:
1. update the script to point to the group+version you want

@ -169,6 +169,7 @@ export interface FeatureToggles {
displayAnonymousStats?: boolean;
alertStateHistoryAnnotationsFromLoki?: boolean;
lokiQueryHints?: boolean;
kubernetesFeatureToggles?: boolean;
alertingPreviewUpgrade?: boolean;
enablePluginsTracingByDefault?: boolean;
cloudRBACRoles?: boolean;

@ -18,22 +18,20 @@ import (
)
func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response {
cfg := hs.Cfg.FeatureManagement
enabledFeatures := hs.Features.GetEnabled(ctx.Req.Context())
// object being returned
dtos := make([]featuremgmt.FeatureToggleDTO, 0)
// loop through features an add features that should be visible to dtos
for _, ft := range hs.featureManager.GetFlags() {
if isFeatureHidden(ft, cfg.HiddenToggles) {
flag := ft.Name
if hs.featureManager.IsHiddenFromAdminPage(flag, false) {
continue
}
dto := featuremgmt.FeatureToggleDTO{
Name: ft.Name,
Name: flag,
Description: ft.Description,
Enabled: enabledFeatures[ft.Name],
ReadOnly: !isFeatureWriteable(ft, cfg.ReadOnlyToggles) || !isFeatureEditingAllowed(*hs.Cfg),
Enabled: hs.featureManager.IsEnabled(ctx.Req.Context(), flag),
ReadOnly: !hs.featureManager.IsEditableFromAdminPage(flag),
}
dtos = append(dtos, dto)
@ -46,7 +44,7 @@ func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.R
}
func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response.Response {
featureMgmtCfg := hs.Cfg.FeatureManagement
featureMgmtCfg := hs.featureManager.Settings
if !featureMgmtCfg.AllowEditing {
return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only"))
}
@ -67,7 +65,7 @@ func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response
for _, t := range cmd.FeatureToggles {
// make sure flag exists, and only continue if flag is writeable
if f, ok := hs.featureManager.LookupFlag(t.Name); ok && isFeatureWriteable(f, hs.Cfg.FeatureManagement.ReadOnlyToggles) {
if hs.featureManager.IsEditableFromAdminPage(t.Name) {
hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login)
payload.FeatureToggles[t.Name] = strconv.FormatBool(t.Enabled)
} else {
@ -92,32 +90,6 @@ func (hs *HTTPServer) GetFeatureMgmtState(ctx *contextmodel.ReqContext) response
return response.Respond(http.StatusOK, fmState)
}
// isFeatureHidden returns whether a toggle should be hidden from the admin page.
// filters out statuses Unknown, Experimental, and Private Preview
func isFeatureHidden(flag featuremgmt.FeatureFlag, hideCfg map[string]struct{}) bool {
if _, ok := hideCfg[flag.Name]; ok {
return true
}
return flag.Stage == featuremgmt.FeatureStageUnknown || flag.Stage == featuremgmt.FeatureStageExperimental || flag.Stage == featuremgmt.FeatureStagePrivatePreview || flag.HideFromAdminPage
}
// isFeatureWriteable returns whether a toggle on the admin page can be updated by the user.
// only allows writing of GA and Deprecated toggles, and excludes the feature toggle admin page toggle
func isFeatureWriteable(flag featuremgmt.FeatureFlag, readOnlyCfg map[string]struct{}) bool {
if _, ok := readOnlyCfg[flag.Name]; ok {
return false
}
if flag.Name == featuremgmt.FlagFeatureToggleAdminPage {
return false
}
return (flag.Stage == featuremgmt.FeatureStageGeneralAvailability || flag.Stage == featuremgmt.FeatureStageDeprecated) && flag.AllowSelfServe
}
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func isFeatureEditingAllowed(cfg setting.Cfg) bool {
return cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != ""
}
type UpdatePayload struct {
FeatureToggles map[string]string `json:"feature_toggles"`
User string `json:"user"`

@ -393,9 +393,8 @@ func runGetScenario(
) []featuremgmt.FeatureToggleDTO {
// Set up server and send request
cfg := setting.NewCfg()
cfg.FeatureManagement = settings
fm := featuremgmt.WithFeatureManager(append([]*featuremgmt.FeatureFlag{{
fm := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, features...), disabled...)
@ -460,9 +459,7 @@ func runSetScenario(
) *http.Response {
// Set up server and send request
cfg := setting.NewCfg()
cfg.FeatureManagement = settings
features := featuremgmt.WithFeatureManager(append([]*featuremgmt.FeatureFlag{{
features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, serverFeatures...), disabled...)

@ -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"`
}

@ -17,59 +17,105 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured": Unstructured{}.OpenAPIDefinition(),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref),
"k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref),
"k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref),
"k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref),
"k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref),
"github.com/grafana/grafana/pkg/apis/common/v0alpha1.ObjectReference": schema_pkg_apis_common_v0alpha1_ObjectReference(ref),
"github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured": Unstructured{}.OpenAPIDefinition(),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref),
"k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref),
"k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref),
"k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref),
"k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref),
"k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref),
}
}
func schema_pkg_apis_common_v0alpha1_ObjectReference(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "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: []string{"object"},
Properties: map[string]spec.Schema{
"resource": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"namespace": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"name": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"apiGroup": {
SchemaProps: spec.SchemaProps{
Description: "APIGroup is the name of the API group that contains the referred object. The empty string represents the core API group.",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion is the version of the API group that contains the referred object.",
Type: []string{"string"},
Format: "",
},
},
},
},
},
}
}

@ -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

@ -14,8 +14,12 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/services/featuremgmt"
grafanaAPIServer "github.com/grafana/grafana/pkg/services/grafana-apiserver"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
)
const (
@ -47,6 +51,12 @@ func (o *APIServerOptions) loadAPIGroupBuilders(args []string) error {
// No dependencies for testing
case "example.grafana.app":
o.builders = append(o.builders, example.NewTestingAPIBuilder())
case "featuretoggle.grafana.app":
features, err := featuremgmt.ProvideManagerService(&setting.Cfg{}, &licensing.OSSLicensingService{})
if err != nil {
return err
}
o.builders = append(o.builders, featuretoggle.NewFeatureFlagAPIBuilder(features))
case "testdata.datasource.grafana.app":
ds, err := datasource.NewStandaloneDatasource(g)
if err != nil {

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/playlist"
)
@ -23,6 +24,7 @@ func ProvideRegistryServiceSink(
_ *dashboard.DashboardsAPIBuilder,
_ *playlist.PlaylistAPIBuilder,
_ *example.TestingAPIBuilder,
_ *featuretoggle.FeatureFlagAPIBuilder,
_ *datasource.DataSourceAPIBuilder,
_ *folders.FolderAPIBuilder,
) *Service {

@ -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
}

@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/playlist"
)
@ -17,6 +18,7 @@ var WireSet = wire.NewSet(
playlist.RegisterAPIService,
dashboard.RegisterAPIService,
example.RegisterAPIService,
featuretoggle.RegisterAPIService,
datasource.RegisterAPIService,
folders.RegisterAPIService,
)

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
)
var (
@ -16,13 +17,15 @@ var (
type FeatureManager struct {
isDevMod bool
restartRequired bool
allowEditing bool
licensing licensing.Licensing
flags map[string]*FeatureFlag
enabled map[string]bool // only the "on" values
startup map[string]bool // the explicit values registered at startup
warnings map[string]string // potential warnings about the flag
log log.Logger
Settings setting.FeatureMgmtSettings
licensing licensing.Licensing
flags map[string]*FeatureFlag
enabled map[string]bool // only the "on" values
startup map[string]bool // the explicit values registered at startup
warnings map[string]string // potential warnings about the flag
log log.Logger
}
// This will merge the flags with the current configuration
@ -141,20 +144,64 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
}
func (fm *FeatureManager) GetState() *FeatureManagerState {
return &FeatureManagerState{RestartRequired: fm.restartRequired, AllowEditing: fm.allowEditing}
return &FeatureManagerState{
RestartRequired: fm.restartRequired,
AllowEditing: fm.Settings.AllowEditing,
}
}
func (fm *FeatureManager) SetRestartRequired() {
fm.restartRequired = true
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func (fm *FeatureManager) IsFeatureEditingAllowed() bool {
return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != ""
}
// Check to see if a feature toggle exists by name
func (fm *FeatureManager) LookupFlag(name string) (FeatureFlag, bool) {
f, ok := fm.flags[name]
if !ok {
return FeatureFlag{}, false
// Flags that can be edited
func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool {
flag, ok := fm.flags[key]
if !ok ||
!fm.IsFeatureEditingAllowed() ||
!flag.AllowSelfServe ||
flag.Name == FlagFeatureToggleAdminPage {
return false
}
return flag.Stage == FeatureStageGeneralAvailability ||
flag.Stage == FeatureStageDeprecated
}
// Flags that should not be shown in the UI (regardless of their state)
func (fm *FeatureManager) IsHiddenFromAdminPage(key string, lenient bool) bool {
_, hide := fm.Settings.HiddenToggles[key]
flag, ok := fm.flags[key]
if !ok || flag.HideFromAdminPage || hide {
return true // unknown flag (should we show it as a warning!)
}
// Explicitly hidden from configs
_, found := fm.Settings.HiddenToggles[key]
if found {
return true
}
if lenient {
return false
}
return *f, true
return flag.Stage == FeatureStageUnknown ||
flag.Stage == FeatureStageExperimental ||
flag.Stage == FeatureStagePrivatePreview
}
// Get the flags that were explicitly set on startup
func (fm *FeatureManager) GetStartupFlags() map[string]bool {
return fm.startup
}
// Perhaps expose the flag warnings
func (fm *FeatureManager) GetWarning() map[string]string {
return fm.warnings
}
func (fm *FeatureManager) SetRestartRequired() {
fm.restartRequired = true
}
// ############# Test Functions #############
@ -193,7 +240,7 @@ func WithManager(spec ...any) *FeatureManager {
// WithFeatureManager is used to define feature toggle manager for testing.
// It should be used when your test feature toggles require metadata beyond `Name` and `Enabled`.
// You should provide a feature toggle Name at a minimum.
func WithFeatureManager(flags []*FeatureFlag, disabled ...string) *FeatureManager {
func WithFeatureManager(cfg setting.FeatureMgmtSettings, flags []*FeatureFlag, disabled ...string) *FeatureManager {
count := len(flags)
features := make(map[string]*FeatureFlag, count)
enabled := make(map[string]bool, count)
@ -211,5 +258,11 @@ func WithFeatureManager(flags []*FeatureFlag, disabled ...string) *FeatureManage
enabled[f.Name] = !dis[f.Name]
}
return &FeatureManager{enabled: enabled, flags: features, startup: enabled, warnings: map[string]string{}}
return &FeatureManager{
Settings: cfg,
enabled: enabled,
flags: features,
startup: enabled,
warnings: map[string]string{},
}
}

@ -1277,6 +1277,16 @@ var (
AllowSelfServe: false,
Created: time.Date(2023, time.December, 18, 12, 0, 0, 0, time.UTC),
},
{
Name: "kubernetesFeatureToggles",
Description: "Use the kubernetes API for feature toggle management in the frontend",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaOperatorExperienceSquad,
AllowSelfServe: false,
Created: time.Date(2023, time.December, 22, 3, 43, 0, 0, time.UTC),
HideFromAdminPage: true,
},
{
Name: "alertingPreviewUpgrade",
Description: "Show Unified Alerting preview and upgrade page in legacy alerting",

@ -20,14 +20,14 @@ var (
func ProvideManagerService(cfg *setting.Cfg, licensing licensing.Licensing) (*FeatureManager, error) {
mgmt := &FeatureManager{
isDevMod: setting.Env != setting.Prod,
licensing: licensing,
flags: make(map[string]*FeatureFlag, 30),
enabled: make(map[string]bool),
startup: make(map[string]bool),
warnings: make(map[string]string),
allowEditing: cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != "",
log: log.New("featuremgmt"),
isDevMod: setting.Env != setting.Prod,
licensing: licensing,
flags: make(map[string]*FeatureFlag, 30),
enabled: make(map[string]bool),
startup: make(map[string]bool),
warnings: make(map[string]string),
Settings: cfg.FeatureManagement,
log: log.New("featuremgmt"),
}
// Register the standard flags

@ -150,6 +150,7 @@ regressionTransformation,preview,@grafana/grafana-bi-squad,2023-11-24,false,fals
displayAnonymousStats,GA,@grafana/identity-access-team,2023-11-29,false,false,false,true
alertStateHistoryAnnotationsFromLoki,experimental,@grafana/alerting-squad,2023-11-30,false,false,true,false
lokiQueryHints,GA,@grafana/observability-logs,2023-12-18,false,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,2023-12-22,false,false,false,true
alertingPreviewUpgrade,experimental,@grafana/alerting-squad,2024-01-03,false,false,true,false
enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,2024-01-09,false,false,true,false
cloudRBACRoles,experimental,@grafana/identity-access-team,2024-01-10,false,false,true,false

1 Name Stage Owner Created requiresDevMode RequiresLicense RequiresRestart FrontendOnly
150 displayAnonymousStats GA @grafana/identity-access-team 2023-11-29 false false false true
151 alertStateHistoryAnnotationsFromLoki experimental @grafana/alerting-squad 2023-11-30 false false true false
152 lokiQueryHints GA @grafana/observability-logs 2023-12-18 false false false true
153 kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad 2023-12-22 false false false true
154 alertingPreviewUpgrade experimental @grafana/alerting-squad 2024-01-03 false false true false
155 enablePluginsTracingByDefault experimental @grafana/plugins-platform-backend 2024-01-09 false false true false
156 cloudRBACRoles experimental @grafana/identity-access-team 2024-01-10 false false true false

@ -611,6 +611,10 @@ const (
// Enables query hints for Loki
FlagLokiQueryHints = "lokiQueryHints"
// FlagKubernetesFeatureToggles
// Use the kubernetes API for feature toggle management in the frontend
FlagKubernetesFeatureToggles = "kubernetesFeatureToggles"
// FlagAlertingPreviewUpgrade
// Show Unified Alerting preview and upgrade page in legacy alerting
FlagAlertingPreviewUpgrade = "alertingPreviewUpgrade"

Loading…
Cancel
Save