Compare commits

...

23 Commits

Author SHA1 Message Date
grambbledook 2665bfc3a3 fix rebase issues 2026-01-07 20:41:11 +01:00
Denis Vodopianov 6eb1f7bd09 Update pkg/setting/setting_feature_toggles.go
Co-authored-by: Dave Henderson <dave.henderson@grafana.com>
2026-01-07 20:41:11 +01:00
grambbledook 52ce9f024c address golint issues 2026-01-07 20:41:11 +01:00
grambbledook 41c90330b5 update tests according to recent changes 2026-01-07 20:41:11 +01:00
grambbledook 63e0a49605 add ff parsing tests to check if types are handled correctly 2026-01-07 20:41:11 +01:00
grambbledook 2f87ad2d98 more tests added 2026-01-07 20:41:11 +01:00
grambbledook 5874561537 add new thiongs 2026-01-07 20:41:11 +01:00
grambbledook 00e66d4408 revert: the rest 2026-01-07 20:41:11 +01:00
grambbledook c7090e2c42 the rest 2026-01-07 20:41:11 +01:00
grambbledook f29424fc1d minor refactoring 2026-01-07 20:41:11 +01:00
grambbledook aa8187c724 finialise the static provider 2026-01-07 20:41:11 +01:00
grambbledook bd9ddaf28f add support of integerts 2026-01-07 20:41:11 +01:00
grambbledook 6517d80c30 initial commit 2026-01-07 20:41:11 +01:00
owensmallwood ab0b05550f Unified Storag: Fix readme (#115957)
* fix readme

* spelling
2026-01-07 19:35:33 +00:00
beejeebus 4518add556 Use a different metric name for new config CRUD APIs
Also, make sure to register the metrics with the same prometheus registerer
as the http server, so that metrics will show up.
2026-01-07 14:28:31 -05:00
Kristina Demeshchik 00b89b0d29 Dashboards: Fix liveNow not working for panels with time shift (#115902)
* relative time for timeshifts

* remove extra assertion

* absolute time range
2026-01-07 14:24:20 -05:00
Todd Treece a3eedfeb73 Plugins: Move fixed role registration behind toggle (#115940) 2026-01-07 13:52:01 -05:00
Renato Costa 1e8f1f74ea unified-storage: apply backwards compatibility changes outside sqlkv (#115954) 2026-01-07 13:51:15 -05:00
owensmallwood 66b05914e2 Tracing: Use service name from config (#115955)
use service name from config
2026-01-07 12:50:11 -06:00
Yunwen Zheng 0c60d356d1 RecentlyViewedDashboards: Hide entire section when there is no recently view item (#115905)
* RecentlyViewedDashboards: Hide entire section when there is no recently view item
2026-01-07 13:31:48 -05:00
Ezequiel Victorero 41d7213d7e Docs: Update dualwrite ini config (#115934) 2026-01-07 17:58:58 +01:00
Todd Treece efad6c7be0 Chore: Update enterprise imports (#115947) 2026-01-07 16:55:59 +00:00
Paulo Dias e116254f32 Alerting: Update createdBy field when silence is being Recreated (#115543) 2026-01-07 16:05:53 +00:00
35 changed files with 738 additions and 220 deletions
+2 -2
View File
@@ -33,12 +33,14 @@ require (
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
@@ -343,7 +345,6 @@ require (
github.com/at-wat/mqtt-go v0.19.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
@@ -358,7 +359,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
@@ -165,9 +165,17 @@ describe('DateMath', () => {
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
});
it('should handle multiple math expressions', () => {
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
it.each([
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]],
['-2d-1d', [2014, 1, 5], [2014, 1, 2]],
['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]],
['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]],
['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]],
['-1w-1d', [2014, 1, 14], [2014, 1, 6]],
])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => {
const date = dateMath.parseDateMath(expression, dateTime(inputDate));
expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf());
});
it('should return false when invalid expression', () => {
+4 -4
View File
@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
}()
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
}()
uid := web.Params(c.Req)[":uid"]
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
}()
cmd := datasources.AddDataSourceCommand{}
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
}()
cmd := datasources.UpdateDataSourceCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
+1 -1
View File
@@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
}, []string{"handler"})
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
return promRegister, dsConfigHandlerRequestsDuration
}
+1 -1
View File
@@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"}),
}, []string{"handler"}),
}
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
+3 -1
View File
@@ -11,6 +11,9 @@ import (
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
_ "github.com/Azure/go-autorest/autorest"
_ "github.com/Azure/go-autorest/autorest/adal"
_ "github.com/aws/aws-sdk-go-v2/credentials"
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
_ "github.com/aws/aws-sdk-go-v2/service/sts"
_ "github.com/beevik/etree"
_ "github.com/blugelabs/bluge"
_ "github.com/blugelabs/bluge_segment_api"
@@ -46,7 +49,6 @@ import (
_ "sigs.k8s.io/randfill"
_ "xorm.io/builder"
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
_ "github.com/grafana/authlib/authn"
_ "github.com/grafana/authlib/authz"
_ "github.com/grafana/authlib/cache"
+1 -1
View File
@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
case "rateLimiting":
return newRateLimiter(ots.cfg.SamplerParam), nil
case "remote":
return jaegerremote.New("grafana",
return jaegerremote.New(ots.cfg.ServiceName,
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
), nil
+17 -4
View File
@@ -57,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
}
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds())
}()
}
return s.datasources.ListDataSources(ctx)
}
@@ -64,7 +70,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
}()
}
@@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
}()
}
@@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
}()
}
@@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
}()
}
@@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
// DeleteCollection implements rest.CollectionDeleter.
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds())
}()
}
dss, err := s.datasources.ListDataSources(ctx)
if err != nil {
return nil, err
+5 -4
View File
@@ -21,6 +21,7 @@ import (
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
@@ -69,10 +70,10 @@ func RegisterAPIService(
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
regErr := reg.Register(dataSourceCRUDMetric)
Name: "ds_config_handler_apis_requests_duration_seconds",
Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
}, []string{"handler"})
regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr
}
+7 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -36,9 +37,13 @@ func ProvideAppInstaller(
pluginStore pluginstore.Store,
pluginAssetsService *pluginassets.Service,
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
features featuremgmt.FeatureToggles,
) (*AppInstaller, error) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
//nolint:staticcheck // not yet migrated to OpenFeature
if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
}
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
+2 -2
View File
@@ -785,7 +785,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
if err != nil {
return nil, err
}
@@ -1447,7 +1447,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
if err != nil {
return nil, err
}
+59
View File
@@ -126,6 +126,63 @@ func (s *FeatureFlagStage) UnmarshalJSON(b []byte) error {
return nil
}
type FeatureFlagType int
const (
Boolean FeatureFlagType = iota
Integer
Float
String
Structure
)
func (t FeatureFlagType) String() string {
switch t {
case Boolean:
return "boolean"
case Integer:
return "integer"
case Float:
return "float"
case String:
return "string"
case Structure:
return "structure"
}
return "unknown"
}
// MarshalJSON marshals the enum as a quoted json string
func (t FeatureFlagType) MarshalJSON() ([]byte, error) {
buffer := bytes.NewBufferString(`"`)
buffer.WriteString(t.String())
buffer.WriteString(`"`)
return buffer.Bytes(), nil
}
func (t *FeatureFlagType) UnmarshalJSON(b []byte) error {
var j string
err := json.Unmarshal(b, &j)
if err != nil {
return err
}
switch j {
case "boolean":
*t = Boolean
case "integer":
*t = Integer
case "float":
*t = Float
case "string":
*t = String
case "structure":
*t = Structure
}
return nil
}
// These are properties about the feature, but not the current state or value for it
type FeatureFlag struct {
Name string `json:"name" yaml:"name"` // Unique name
@@ -135,6 +192,8 @@ type FeatureFlag struct {
// CEL-GO expression. Using the value "true" will mean this is on by default
Expression string `json:"expression,omitempty"`
// Type of the feature flag (boolean, number, string, structure),
Type FeatureFlagType `json:"type,omitempty"`
// Special behavior properties
RequiresDevMode bool `json:"requiresDevMode,omitempty"` // can not be enabled in production
+3 -3
View File
@@ -26,7 +26,7 @@ type OpenFeatureConfig struct {
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
HTTPClient *http.Client
// StaticFlags are the feature flags to use with static provider
StaticFlags map[string]bool
StaticFlags map[string]setting.FeatureToggle
// TargetingKey is used for evaluation context
TargetingKey string
// ContextAttrs are additional attributes for evaluation context
@@ -100,7 +100,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]bool,
staticFlags map[string]setting.FeatureToggle,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
@@ -117,7 +117,7 @@ func createProvider(
}
}
return newStaticProvider(staticFlags)
return newStaticProvider(staticFlags, standardFeatureFlags)
}
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
+1 -1
View File
@@ -47,7 +47,7 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
}
mgmt.warnings[key] = "unknown flag in config"
}
mgmt.startup[key] = val
mgmt.startup[key] = val.Value == true
}
// update the values
+1 -1
View File
@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
}
staticProvider, err := newStaticProvider(staticFlags)
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
if err != nil {
return nil, fmt.Errorf("failed to create static provider: %w", err)
}
+63 -19
View File
@@ -1,6 +1,11 @@
package featuremgmt
import (
"encoding/json"
"fmt"
"strconv"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
)
@@ -28,37 +33,76 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
func newStaticProvider(confFlags map[string]setting.FeatureToggle, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
index := make(map[string]FeatureFlag, len(standardFlags))
// Add standard flags
for _, flag := range standardFlags {
inMemFlag, err := createTypedFlag(flag)
if err != nil {
return nil, err
}
// Add flags from config.ini file
for name, value := range confFlags {
flags[name] = createInMemoryFlag(name, value)
flags[flag.Name] = inMemFlag
index[flag.Name] = flag
}
// Add standard flags
for _, flag := range standardFeatureFlags {
if _, exists := flags[flag.Name]; !exists {
enabled := flag.Expression == "true"
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
// Add flags from config.ini file
for name, flag := range confFlags {
standard, exists := index[flag.Name]
// Fail fast if a flag is declared with a mismatched type
if exists && standard.Type.String() != string(flag.Type) {
return nil, fmt.Errorf("type mismatch for flag '%s' detected", flag.Name)
}
flags[name] = createInMemoryFlag(flag)
}
return newInMemoryBulkProvider(flags), nil
}
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
variant := "disabled"
if enabled {
variant = "enabled"
}
func createInMemoryFlag(flag setting.FeatureToggle) memprovider.InMemoryFlag {
variant := "default"
return memprovider.InMemoryFlag{
Key: name,
Key: flag.Name,
DefaultVariant: variant,
Variants: map[string]interface{}{
"enabled": true,
"disabled": false,
Variants: map[string]any{
variant: flag.Value,
},
}
}
func createTypedFlag(flag FeatureFlag) (memprovider.InMemoryFlag, error) {
defaultVariant := "default"
var value any
var err error
switch flag.Type {
case Boolean:
value = flag.Expression == "true"
case Integer:
value, err = strconv.Atoi(flag.Expression)
case Float:
value, err = strconv.ParseFloat(flag.Expression, 64)
case String:
value = flag.Expression
case Structure:
err = json.Unmarshal([]byte(flag.Expression), &value)
default:
return memprovider.InMemoryFlag{}, fmt.Errorf("unsupported flag type %s", flag.Type)
}
if err != nil {
return memprovider.InMemoryFlag{}, err
}
return memprovider.InMemoryFlag{
Key: flag.Name,
DefaultVariant: defaultVariant,
Variants: map[string]any{
defaultVariant: value,
},
}, nil
}
@@ -93,3 +93,178 @@ ABCD = true
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}
func Test_StaticProvider_FailfastOnMismatchedType(t *testing.T) {
staticFlags := map[string]setting.FeatureToggle{"oldBooleanFlag": {
Type: setting.Boolean,
Name: "oldBooleanFlag",
Value: true,
}}
flag := FeatureFlag{
Name: "oldBooleanFlag",
Expression: "1.0",
Type: Float,
}
_, err := newStaticProvider(staticFlags, []FeatureFlag{flag})
assert.EqualError(t, err, "type mismatch for flag 'oldBooleanFlag' detected")
}
func Test_StaticProvider_TypedFlags(t *testing.T) {
tests := []struct {
flags FeatureFlag
defaultValue any
expectedValue any
}{
{
flags: FeatureFlag{
Name: "Flag",
Expression: "true",
Type: Boolean,
},
defaultValue: false,
expectedValue: true,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1.0",
Type: Float,
},
defaultValue: 0.0,
expectedValue: 1.0,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "blue",
Type: String,
},
defaultValue: "red",
expectedValue: "blue",
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1",
Type: Integer,
},
defaultValue: int64(0),
expectedValue: int64(1),
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: `{ "foo": "bar" }`,
Type: Structure,
},
defaultValue: nil,
expectedValue: map[string]any{"foo": "bar"},
},
}
for _, tt := range tests {
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
assert.NoError(t, err)
var result any
switch tt.flags.Type {
case Boolean:
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
case Float:
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
case String:
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
case Integer:
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
case Structure:
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.expectedValue, result)
}
}
func Test_StaticProvider_ConfigOverride(t *testing.T) {
tests := []struct {
name string
typ FeatureFlagType
originalValue string
configValue any
}{
{
name: "bool",
typ: Boolean,
originalValue: "false",
configValue: true,
},
{
name: "int",
typ: Integer,
originalValue: "0",
configValue: int64(1),
},
{
name: "float",
typ: Float,
originalValue: "0.0",
configValue: 1.0,
},
{
name: "string",
typ: String,
originalValue: "foo",
configValue: "bar",
},
{
name: "structure",
typ: Structure,
originalValue: "{}",
configValue: make(map[string]any),
},
}
for _, tt := range tests {
configFlags, standardFlags := makeFlags(tt)
provider, err := newStaticProvider(configFlags, standardFlags)
assert.NoError(t, err)
var result any
switch tt.typ {
case Boolean:
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
case Float:
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
case String:
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
case Integer:
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
case Structure:
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.configValue, result)
}
}
func makeFlags(tt struct {
name string
typ FeatureFlagType
originalValue string
configValue any
}) (map[string]setting.FeatureToggle, []FeatureFlag) {
orig := FeatureFlag{
Name: tt.name,
Expression: tt.originalValue,
Type: tt.typ,
}
config := map[string]setting.FeatureToggle{
tt.name: {
Name: tt.name,
Type: setting.FeatureFlagType(tt.typ.String()),
Value: tt.configValue,
},
}
return config, []FeatureFlag{orig}
}
+2 -2
View File
@@ -378,8 +378,8 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
ProviderType: setting.StaticProviderType,
StaticFlags: map[string]bool{
featuremgmt.FlagPluginsAutoUpdate: flagValue,
StaticFlags: map[string]setting.FeatureToggle{
featuremgmt.FlagPluginsAutoUpdate: {Value: flagValue, Type: setting.Boolean},
},
})
require.NoError(t, err)
+59 -5
View File
@@ -1,6 +1,8 @@
package setting
import (
"encoding/json"
"fmt"
"strconv"
"gopkg.in/ini.v1"
@@ -8,6 +10,22 @@ import (
"github.com/grafana/grafana/pkg/util"
)
type FeatureFlagType string
const (
Structure FeatureFlagType = "structure"
Integer FeatureFlagType = "integer"
Float FeatureFlagType = "float"
Boolean FeatureFlagType = "boolean"
String FeatureFlagType = "string"
)
type FeatureToggle struct {
Type FeatureFlagType `json:"type"`
Name string `json:"name"`
Value any `json:"value"`
}
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -15,18 +33,30 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
return toggle.Type == Boolean && toggle.Value == true
}
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]FeatureToggle, error) {
featureToggles := make(map[string]FeatureToggle, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = true
featureToggles[feature] = FeatureToggle{
Type: Boolean,
Name: feature,
Value: true,
}
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -36,12 +66,36 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := strconv.ParseBool(v.Value())
b, err := ParseFlag(v.Name(), v.Value())
if err != nil {
return featureToggles, err
}
flag, exists := featureToggles[v.Name()]
if exists && flag.Type != b.Type {
return nil, fmt.Errorf("type mismatch during flag declaration '%s': %s, %s", v.Name(), flag.Type, b.Type)
}
featureToggles[v.Name()] = b
}
return featureToggles, nil
}
func ParseFlag(name, value string) (FeatureToggle, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return FeatureToggle{Type: Integer, Name: name, Value: integer}, nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return FeatureToggle{Type: Float, Name: name, Value: float}, nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return FeatureToggle{Type: Structure, Name: name, Value: structure}, nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return FeatureToggle{Type: Boolean, Name: name, Value: boolean}, nil
}
return FeatureToggle{Type: String, Name: name, Value: value}, nil
}
+36 -17
View File
@@ -1,7 +1,7 @@
package setting
import (
"strconv"
"errors"
"testing"
"github.com/stretchr/testify/require"
@@ -13,16 +13,16 @@ func TestFeatureToggles(t *testing.T) {
name string
conf map[string]string
err error
expectedToggles map[string]bool
expectedToggles map[string]FeatureToggle
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
expectedToggles: map[string]FeatureToggle{
"feature1": {Name: "feature1", Type: Boolean, Value: true},
"feature2": {Name: "feature2", Type: Boolean, Value: true},
},
},
{
@@ -31,10 +31,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
expectedToggles: map[string]FeatureToggle{
"feature1": {Name: "feature1", Type: Boolean, Value: true},
"feature2": {Name: "feature2", Type: Boolean, Value: true},
"feature3": {Name: "feature3", Type: Boolean, Value: true},
},
},
{
@@ -43,19 +43,35 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
expectedToggles: map[string]FeatureToggle{
"feature1": {Name: "feature1", Type: Boolean, Value: true},
"feature2": {Name: "feature2", Type: Boolean, Value: false},
},
},
{
name: "invalid boolean value should return syntax error",
name: "conflict in type declaration is be detected",
conf: map[string]string{
"enable": "feature1,feature2",
"feature2": "invalid",
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
expectedToggles: map[string]FeatureToggle{},
err: errors.New("type mismatch during flag declaration 'feature2': boolean, string"),
},
{
name: "type of the feature flag is handled correctly",
conf: map[string]string{
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]FeatureToggle{
"feature1": {Name: "feature1", Type: Integer, Value: 1},
"feature2": {Name: "feature2", Type: Float, Value: 1.0},
"feature3": {Name: "feature3", Type: Structure, Value: map[string]any{"foo": "bar"}},
"feature4": {Name: "feature4", Type: String, Value: "bar"},
"feature5": {Name: "feature5", Type: Boolean, Value: true},
"feature6": {Name: "feature6", Type: Boolean, Value: true},
},
},
}
@@ -69,11 +85,14 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.ErrorIs(t, err, tc.err)
if tc.err != nil {
require.EqualError(t, err, tc.err.Error())
}
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
+7 -6
View File
@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
overrides_reload_period = 5s
```
To overrides the default quota for a tenant, add the following to the overrides.yaml file:
To override the default quota for a tenant, add the following to the `overrides.yaml` file:
```yaml
overrides:
<NAMESPACE>:
quotas:
<GROUP>.<RESOURCE>:
<GROUP>/<RESOURCE>:
limit: 10
```
Unless otherwise set, the NAMESPACE when running locally is `default`.
Unless otherwise set, the `NAMESPACE` when running locally is `default`.
To access quotas, use the following API endpoint:
```
@@ -806,8 +806,10 @@ flowchart TD
#### Setting Dual Writer Mode
```ini
[unified_storage.{resource}.{kind}.{group}]
dualWriterMode = {0-5}
; [unified_storage.{resource}.{group}]
[unified_storage.dashboards.dashboard.grafana.app]
; modes {0-5}
dualWriterMode = 0
```
#### Background Sync Configuration
@@ -1376,4 +1378,3 @@ disable_data_migrations = false
### Documentation
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
@@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }}
{{ .Ident "previous_resource_version" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
{{ .Arg .GUID }},
{{ .Arg .Group }},
{{ .Arg .Resource }},
@@ -19,13 +19,5 @@ VALUES (
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END
{{ .Arg .PreviousRV }}
);
@@ -7,9 +7,7 @@ INSERT INTO {{ .Ident "resource_history" }}
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }},
{{ .Ident "folder" }},
{{ .Ident "previous_resource_version" }},
{{ .Ident "generation" }}
{{ .Ident "folder" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
@@ -19,26 +17,5 @@ VALUES (
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END,
CASE
WHEN {{ .Arg .Action }} = 1 THEN 1
WHEN {{ .Arg .Action }} = 3 THEN 0
ELSE 1 + (
SELECT COUNT(1)
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
)
END
{{ .Arg .Folder }}
);
@@ -1,8 +1,10 @@
UPDATE {{ .Ident "resource" }}
SET
{{ .Ident "value" }} = {{ .Arg .Value }},
{{ .Ident "guid" }} = {{ .Arg .GUID }},
{{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
{{ .Ident "action" }} = {{ .Arg .Action }},
{{ .Ident "folder" }} = {{ .Arg .Folder }}
{{ .Ident "folder" }} = {{ .Arg .Folder }},
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
@@ -0,0 +1,5 @@
UPDATE {{ .Ident "resource_history" }}
SET
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
{{ .Ident "generation" }} = {{ .Arg .Generation }}
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};
+122 -5
View File
@@ -12,6 +12,9 @@ import (
"time"
"github.com/grafana/grafana/pkg/apimachinery/validation"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
gocache "github.com/patrickmn/go-cache"
)
@@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
}
if rv == 0 {
rv = math.MaxInt64
}
listKey := ListRequestKey(key)
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
@@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) {
}, nil
}
// Temporary while we need to support unified/sql/backend compatibility
// Temporary while we need to support unified/sql/backend compatibility.
// Remove once we stop using RvManager in storage_backend.go
func ParseKeyWithGUID(key string) (DataKey, error) {
parts := strings.Split(key, "/")
@@ -815,3 +814,121 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
return results, nil
}
// TODO: remove when backwards compatibility is no longer needed.
var (
sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql")
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
)
// TODO: remove when backwards compatibility is no longer needed.
type sqlKVLegacySaveRequest struct {
sqltemplate.SQLTemplate
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
PreviousRV int64
}
func (req sqlKVLegacySaveRequest) Validate() error {
return nil
}
// TODO: remove when backwards compatibility is no longer needed.
type sqlKVLegacyUpdateHistoryRequest struct {
sqltemplate.SQLTemplate
GUID string
PreviousRV int64
Generation int64
}
func (req sqlKVLegacyUpdateHistoryRequest) Validate() error {
return nil
}
// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables
// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend.
// Specifically, it will update the `resource_history` table to include the previous resource version
// and generation, which come from the `WriteEvent`, and also make the corresponding change on the
// `resource` table, no longer used in the storage backend.
//
// TODO: remove when backwards compatibility is no longer needed.
func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error {
kv, isSQLKV := d.kv.(*sqlKV)
if !isSQLKV {
return nil
}
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
PreviousRV: event.PreviousRV,
Generation: event.Object.GetGeneration(),
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
}
var action int64
switch key.Action {
case DataActionCreated:
action = 1
case DataActionUpdated:
action = 2
case DataActionDeleted:
action = 3
}
switch key.Action {
case DataActionCreated:
_, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
Action: action,
Folder: key.Folder,
PreviousRV: event.PreviousRV,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
}
case DataActionUpdated:
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
Folder: key.Folder,
PreviousRV: event.PreviousRV,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to update resource: %w", err)
}
case DataActionDeleted:
_, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err)
}
}
return nil
}
+8 -75
View File
@@ -44,8 +44,6 @@ var (
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
@@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error {
return req.sqlKVSectionKey.Validate()
}
type sqlKVLegacySaveRequest struct {
sqltemplate.SQLTemplate
Value []byte
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
}
func (req sqlKVLegacySaveRequest) Validate() error {
return nil
}
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
return req.Value, nil
}
type sqlKVKeysRequest struct {
sqltemplate.SQLTemplate
sqlKVSection
@@ -392,7 +370,7 @@ func (w *sqlWriteCloser) Close() error {
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
tx, ok := rvmanager.TxFromCtx(w.ctx)
if !ok {
// temporary save for dataStore without rvmanager
// temporary save for dataStore without rvmanager (non backwards-compatible)
// we can use the same template as the event one after we:
// - move PK from GUID to key_path
// - remove all unnecessary columns (or at least their NOT NULL constraints)
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
return nil
}
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
// as the RvManager will be responsible for this
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
// for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns.
// For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
// table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
// storage_backend for the full implementation.
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
if err != nil {
return fmt.Errorf("failed to parse key: %w", err)
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
case DataActionDeleted:
action = 3
default:
return fmt.Errorf("failed to parse key: %w", err)
return fmt.Errorf("failed to parse key: invalid action")
}
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
@@ -468,52 +447,6 @@ func (w *sqlWriteCloser) Close() error {
return fmt.Errorf("failed to save to resource_history: %w", err)
}
switch dataKey.Action {
case DataActionCreated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
GUID: dataKey.GUID,
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to insert to resource: %w", err)
}
case DataActionUpdated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to update resource: %w", err)
}
case DataActionDeleted:
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
})
if err != nil {
return fmt.Errorf("failed to delete from resource: %w", err)
}
}
return nil
}
@@ -332,11 +332,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
dataKey.GUID = uuid.New().String()
var err error
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
if err != nil {
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
return "", fmt.Errorf("failed to write data: %w", err)
}
if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil {
return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err)
}
return dataKey.GUID, nil
})
if err != nil {
+1 -1
View File
@@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) {
"value": true,
"key":"`+flag+`",
"reason":"static provider evaluation result",
"variant":"enabled"}`, string(rsp.Body))
"variant":"default"}`, string(rsp.Body))
})
}
+4
View File
@@ -10,6 +10,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"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"
@@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
AppModeProduction: true,
DisableAnonymous: true,
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
EnableFeatureToggles: []string{
featuremgmt.FlagPluginStoreServiceLoading,
},
})
t.Cleanup(func() { helper.Shutdown() })
return helper
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
startsAt: interval.start.toISOString(),
endsAt: interval.end.toISOString(),
comment: silence.comment,
createdBy: silence.createdBy,
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
duration: intervalToAbbreviatedDurationString(interval),
isRegex: false,
matchers: silence.matchers?.map(matcherToMatcherField) || [],
@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
retry();
};
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
return null;
}
@@ -76,10 +76,6 @@ export function RecentlyViewedDashboards() {
</>
)}
{loading && <Spinner />}
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
{!loading && recentDashboards.length === 0 && (
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
)}
{!loading && recentDashboards.length > 0 && (
<ul className={styles.list}>
@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
});
it('should handle invalid time reference in timeShift', () => {
it('should handle invalid time reference in timeShift with relative time range', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
buildAndActivateSceneFor(panelTime);
@@ -139,6 +139,22 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.to).toBe('now');
});
it('should handle invalid time reference in timeShift with absolute time range', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const absoluteFrom = '2019-02-11T10:00:00.000Z';
const absoluteTo = '2019-02-11T16:00:00.000Z';
const scene = new SceneFlexLayout({
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.timeInfo).toBe('invalid timeshift');
expect(panelTime.state.from).toBe(absoluteFrom);
expect(panelTime.state.to).toBe(absoluteTo);
});
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
const panelTime = new PanelTimeRange({
timeFrom: 'now-2h',
@@ -153,6 +169,66 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.to).toBe('now');
});
describe('from/to state format for liveNow compatibility', () => {
it('should store relative strings in from/to when timeShift is applied to relative time range', () => {
const panelTime = new PanelTimeRange({ timeShift: '2h' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.from).toBe('now-6h-2h');
expect(panelTime.state.to).toBe('now-2h');
expect(panelTime.state.value.raw.from).toBe('now-6h-2h');
expect(panelTime.state.value.raw.to).toBe('now-2h');
});
it('should store relative strings when both timeFrom and timeShift are applied', () => {
const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.from).toBe('now-2h-1h');
expect(panelTime.state.to).toBe('now-1h');
});
it('should store ISO strings when timeShift is applied to absolute time range', () => {
const panelTime = new PanelTimeRange({ timeShift: '1h' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const absoluteFrom = '2019-02-11T10:00:00.000Z';
const absoluteTo = '2019-02-11T16:00:00.000Z';
const scene = new SceneFlexLayout({
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z');
expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z');
});
it('should update from/to when ancestor time range changes', () => {
const panelTime = new PanelTimeRange({ timeShift: '1h' });
const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const scene = new SceneFlexLayout({
$timeRange: sceneTimeRange,
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.from).toBe('now-6h-1h');
expect(panelTime.state.to).toBe('now-1h');
sceneTimeRange.onTimeRangeChange({
from: dateTime('2019-02-11T12:00:00.000Z'),
to: dateTime('2019-02-11T18:00:00.000Z'),
raw: { from: 'now-12h', to: 'now' },
});
expect(panelTime.state.from).toBe('now-12h-1h');
expect(panelTime.state.to).toBe('now-1h');
});
});
describe('onTimeRangeChange', () => {
it('should reverse timeShift when updating time range', () => {
const oneHourShift = '1h';
@@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
}
const overrideResult = this.getTimeOverride(timeRange.value);
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
const { timeRange: overrideTimeRange } = overrideResult;
this.setState({
value: overrideTimeRange,
timeInfo: overrideResult.timeInfo,
from:
typeof overrideTimeRange.raw.from === 'string'
? overrideTimeRange.raw.from
: overrideTimeRange.raw.from.toISOString(),
to:
typeof overrideTimeRange.raw.to === 'string'
? overrideTimeRange.raw.to
: overrideTimeRange.raw.to.toISOString(),
});
}
// Get a time shifted request to compare with the primary request.
@@ -153,10 +165,10 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
// Only evaluate if the timeFrom if parent time is relative
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
const timeZone = this.getTimeZone();
const timezone = this.getTimeZone();
newTimeData.timeRange = {
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
};
infoBlocks.push(timeFromInfo.display);
@@ -172,18 +184,39 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
return newTimeData;
}
const timeShift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + timeShift);
const shift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + shift);
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
const timezone = this.getTimeZone();
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
const from = dateMath.toDateTime(rawFromShifted, { timezone });
const to = dateMath.toDateTime(rawToShifted, { timezone });
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
}
newTimeData.timeRange = {
from,
to,
raw: { from: rawFromShifted, to: rawToShifted },
};
} else {
const from = dateMath.parseDateMath(shift, newTimeData.timeRange.from, false);
const to = dateMath.parseDateMath(shift, newTimeData.timeRange.to, true);
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
}
newTimeData.timeRange = { from, to, raw: { from, to } };
}
newTimeData.timeRange = { from, to, raw: { from, to } };
}
if (compareWith) {
-1
View File
@@ -3759,7 +3759,6 @@
},
"recently-viewed": {
"clear": "Clear history",
"empty": "Nothing viewed yet",
"error": "Recently viewed dashboards couldnt be loaded.",
"retry": "Retry",
"title": "Recently viewed"