Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2106a5fed7 | |||
| d0217588a3 | |||
| ce9ab6a89a | |||
| 8c8efd2494 | |||
| c5d88706c7 | |||
| ff10b085f6 | |||
| 0809124b11 | |||
| 645e55cd68 | |||
| 69ccfd6bfc | |||
| 12fe570ce3 | |||
| 452b0acf09 | |||
| f136b23a7e | |||
| 9f28da85e2 | |||
| 9be719c9aa | |||
| f1e388ff4e | |||
| a30a5f2022 | |||
| a3b1c68ac0 | |||
| e347d500d0 | |||
| 88bc65d383 |
+7
-1
@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
|
||||
rudderstack_sdk_url =
|
||||
|
||||
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
|
||||
rudderstack_v3_sdk_url =
|
||||
rudderstack_v3_sdk_url =
|
||||
|
||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||
rudderstack_config_url =
|
||||
@@ -2079,8 +2079,14 @@ enable =
|
||||
# To enable features by default, set `Expression: "true"` in:
|
||||
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
|
||||
|
||||
# The feature_toggles section supports feature flags of a number of types,
|
||||
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
|
||||
#
|
||||
# feature1 = true
|
||||
# feature2 = false
|
||||
# feature3 = "foobar"
|
||||
# feature4 = 1.5
|
||||
# feature5 = { "foo": "bar" }
|
||||
|
||||
[feature_toggles.openfeature]
|
||||
# This is EXPERIMENTAL. Please, do not use this section
|
||||
|
||||
+8
-3
@@ -323,7 +323,7 @@
|
||||
;rudderstack_sdk_url =
|
||||
|
||||
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
|
||||
;rudderstack_v3_sdk_url =
|
||||
;rudderstack_v3_sdk_url =
|
||||
|
||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||
;rudderstack_config_url =
|
||||
@@ -1913,7 +1913,7 @@ default_datasource_uid =
|
||||
|
||||
# client_queue_max_size is the maximum size in bytes of the client queue
|
||||
# for Live connections. Defaults to 4MB.
|
||||
;client_queue_max_size =
|
||||
;client_queue_max_size =
|
||||
|
||||
#################################### Grafana Image Renderer Plugin ##########################
|
||||
[plugin.grafana-image-renderer]
|
||||
@@ -1996,9 +1996,14 @@ default_datasource_uid =
|
||||
|
||||
;enable = feature1,feature2
|
||||
|
||||
# The feature_toggles section supports feature flags of a number of types,
|
||||
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
|
||||
|
||||
;feature1 = true
|
||||
;feature2 = false
|
||||
|
||||
;feature3 = "foobar"
|
||||
;feature4 = 1.5
|
||||
;feature5 = { "foo": "bar" }
|
||||
[date_formats]
|
||||
# For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/
|
||||
|
||||
|
||||
@@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../
|
||||
|
||||
Keys of features to enable, separated by space.
|
||||
|
||||
#### `FEATURE_TOGGLE_NAME = false`
|
||||
#### `FEATURE_NAME = <value>`
|
||||
|
||||
Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`.
|
||||
Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details.
|
||||
|
||||
For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ The `export` endpoints allow you to export alerting resources in a JSON format s
|
||||
|
||||
### Alert rules
|
||||
|
||||
The following endpoints can be used to manage both alert rules and recording rules. To create a recording rule, include a `record` block in your request instead of a `condition` field.
|
||||
|
||||
| Method | URI | Name | Summary |
|
||||
| ------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| DELETE | /api/v1/provisioning/alert-rules/:uid | [route delete alert rule](#route-delete-alert-rule) | Delete a specific alert rule by UID. |
|
||||
@@ -53,7 +51,6 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
"noDataState": "OK",
|
||||
"execErrState": "OK",
|
||||
"for": "5m",
|
||||
"keepFiringFor": "2m",
|
||||
"orgId": 1,
|
||||
"uid": "",
|
||||
"condition": "B",
|
||||
@@ -203,7 +200,6 @@ Content-Type: application/json
|
||||
### Notification template groups
|
||||
|
||||
Template groups enable you to define multiple notification templates (`{{ define "" }}`) within a single group. They can be managed from the Grafana Alerting UI.
|
||||
"type": "__expr__",
|
||||
|
||||
| Method | URI | Name | Summary |
|
||||
| ------ | ------------------------------------ | ----------------------------------------------- | ----------------------------------------------- |
|
||||
@@ -217,103 +213,6 @@ Content-Type: application/json
|
||||
```http
|
||||
GET /api/v1/provisioning/templates
|
||||
Accept: application/json
|
||||
"for": "0s",
|
||||
"keep_firing_for": "0s",
|
||||
"isPaused": false,
|
||||
"notification_settings": null,
|
||||
"record": {
|
||||
"metric": "grafana_recording_rule_test_2",
|
||||
"from": "B",
|
||||
"target_datasource_uid": "grafanacloud-prom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contact points
|
||||
|
||||
| Method | URI | Name | Summary |
|
||||
| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| DELETE | /api/v1/provisioning/contact-points/:uid | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. |
|
||||
| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. |
|
||||
| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. |
|
||||
| PUT | /api/v1/provisioning/contact-points/:uid | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. |
|
||||
| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. |
|
||||
|
||||
**Example Request for all the contact points:**
|
||||
|
||||
```http
|
||||
GET /api/v1/provisioning/contact-points
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
### Notification policies
|
||||
|
||||
| Method | URI | Name | Summary |
|
||||
| ------ | ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| DELETE | /api/v1/provisioning/policies | [route reset policy tree](#route-reset-policy-tree) | Clears the notification policy tree. |
|
||||
| GET | /api/v1/provisioning/policies | [route get policy tree](#route-get-policy-tree) | Get the notification policy tree. |
|
||||
| PUT | /api/v1/provisioning/policies | [route put policy tree](#route-put-policy-tree) | Sets the notification policy tree. |
|
||||
| GET | /api/v1/provisioning/policies/export | [route get policy tree export](#route-get-policy-tree-export) | Export the notification policy tree in provisioning file format. |
|
||||
|
||||
**Example Request for exporting the notification policy tree in YAML format:**
|
||||
|
||||
```http
|
||||
GET /api/v1/provisioning/policies/export?format=yaml
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/yaml
|
||||
|
||||
apiVersion: 1
|
||||
policies:
|
||||
- orgId: 1
|
||||
receiver: My Contact Email Point
|
||||
group_by:
|
||||
- grafana_folder
|
||||
- alertname
|
||||
routes:
|
||||
- receiver: My Contact Email Point
|
||||
object_matchers:
|
||||
- - monitor
|
||||
- =
|
||||
- testdata
|
||||
mute_time_intervals:
|
||||
- weekends
|
||||
```
|
||||
|
||||
### Notification template groups
|
||||
|
||||
Template groups enable you to define multiple notification templates (`{{ define "" }}`) within a single group. They can be managed from the Grafana Alerting UI.
|
||||
|
||||
| Method | URI | Name | Summary |
|
||||
| ------ | ------------------------------------ | ----------------------------------------------- | ----------------------------------------------- |
|
||||
| DELETE | /api/v1/provisioning/templates/:name | [route delete template](#route-delete-template) | Delete a notification template group. |
|
||||
| GET | /api/v1/provisioning/templates/:name | [route get template](#route-get-template) | Get a notification template group. |
|
||||
| GET | /api/v1/provisioning/templates | [route get template](#route-get-templates) | Get all notification template groups. |
|
||||
| PUT | /api/v1/provisioning/templates/:name | [route put template](#route-put-template) | Create or update a notification template group. |
|
||||
|
||||
**Example Request for all notification template groups:**
|
||||
|
||||
```http
|
||||
GET /api/v1/provisioning/templates
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
@@ -1652,21 +1551,20 @@ Status: Accepted
|
||||
|
||||
**Properties**
|
||||
|
||||
{{% responsive-table %}}
|
||||
|
||||
| Name | Type | Go type | Required | Default | Description | Example |
|
||||
| ------- | ------------------------ | --------- | :------: | ------- | ----------- | ------- |
|
||||
| `Name` | string | string | | | | |
|
||||
| `Type` | [MatchType](#match-type) | MatchType | | | | |
|
||||
| `Value` | string | string | | | | |
|
||||
|
||||
{{% /responsive-table %}}
|
||||
|
||||
### <span id="matchers"></span> Matchers
|
||||
|
||||
> Matchers is a slice of Matchers that is sortable, implements Stringer, and
|
||||
> provides a Matches method to match a LabelSet against all Matchers in the
|
||||
> slice. Note that some users of Matchers might require it to be sorted.
|
||||
{{% responsive-table %}}
|
||||
|
||||
| Name | Type | Go type | Required | Default | Description | Example |
|
||||
| ------- | ------------------------ | --------- | :------: | ------- | ----------- | ------- |
|
||||
| `Name` | string | string | | | | |
|
||||
| `Type` | [MatchType](#match-type) | MatchType | | | | |
|
||||
| `Value` | string | string | | | | |
|
||||
|
||||
{{% /responsive-table %}}
|
||||
|
||||
### <span id="matchers"></span> Matchers
|
||||
|
||||
> Matchers is a slice of Matchers that is sortable, implements Stringer, and
|
||||
> provides a Matches method to match a LabelSet against all Matchers in the
|
||||
> slice. Note that some users of Matchers might require it to be sorted.
|
||||
|
||||
[][Matcher](#matcher)
|
||||
@@ -1886,25 +1784,24 @@ When creating a contact point, the `EmbeddedContactPoint.name` property determin
|
||||
{{% responsive-table %}}
|
||||
|
||||
| Name | Type | Go type | Required | Default | Description | Example |
|
||||
| --------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- |
|
||||
| `days_of_month` | []string | []string | | | | |
|
||||
| `location` | string | string | | | | |
|
||||
| `months` | []string | []string | | | | |
|
||||
| `times` | [][TimeRange](#time-range) | `[]*TimeRange` | | | | |
|
||||
| `weekdays` | []string | []string | | | | |
|
||||
| `years` | []string | []string | | | | |
|
||||
|
||||
{{% /responsive-table %}}
|
||||
|
||||
### <span id="time-range"></span> TimeRange
|
||||
|
||||
> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440.
|
||||
|
||||
**Properties**
|
||||
|
||||
{{% responsive-table %}}
|
||||
|
||||
| Name | Type | Go type | Required | Default | Description | Example |
|
||||
| --------------- | -------------------------- | -------------- | :------: | ------- | ----------- | ------- |
|
||||
| `days_of_month` | []string | []string | | | | |
|
||||
| `location` | string | string | | | | |
|
||||
| `months` | []string | []string | | | | |
|
||||
| `times` | [][TimeRange](#time-range) | `[]*TimeRange` | | | | |
|
||||
| `weekdays` | []string | []string | | | | |
|
||||
| `years` | []string | []string | | | | |
|
||||
|
||||
{{% /responsive-table %}}
|
||||
|
||||
### <span id="time-range"></span> TimeRange
|
||||
|
||||
> For example, 4:00PM to End of the day would Begin at 1020 and End at 1440.
|
||||
|
||||
**Properties**
|
||||
|
||||
{{% responsive-table %}}
|
||||
|
||||
| Name | Type | Go type | Required | Default | Description | Example |
|
||||
| ------------ | ------ | ------- | :------: | ------- | ----------- | ----------------------- |
|
||||
| `end_time` | string | string | | | | `"end_time": "24:00"` |
|
||||
|
||||
@@ -622,10 +622,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
exploreLogsAggregatedMetrics?: boolean;
|
||||
/**
|
||||
* Used in Logs Drilldown to limit the time range
|
||||
*/
|
||||
exploreLogsLimitedTimeRange?: boolean;
|
||||
/**
|
||||
* Enables the gRPC client to authenticate with the App Platform by using ID & access tokens
|
||||
*/
|
||||
appPlatformGrpcClientAuth?: boolean;
|
||||
|
||||
@@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
// gets dashboards that the user was granted read access to
|
||||
permissions := user.GetPermissions()
|
||||
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
|
||||
folderPermissions := permissions[dashboards.ActionFoldersRead]
|
||||
dashboardUids := make([]string, 0)
|
||||
sharedDashboards := make([]string, 0)
|
||||
|
||||
@@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, folderPermission := range folderPermissions {
|
||||
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
|
||||
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
|
||||
dashboardUids = append(dashboardUids, folderUid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dashboardUids) == 0 {
|
||||
return sharedDashboards, nil
|
||||
@@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(dashboardUids)),
|
||||
Federated: []*resourcepb.ResourceKey{folderKey},
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(dashboardUids)),
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: key,
|
||||
Fields: []*resourcepb.Requirement{{
|
||||
@@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}
|
||||
}
|
||||
|
||||
// only folders the user has access to will be returned here
|
||||
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderSearchRequest := &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(allFolders)),
|
||||
@@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
|
||||
}},
|
||||
},
|
||||
}
|
||||
// only folders the user has access to will be returned here
|
||||
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
|
||||
@@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
[]byte("publicfolder"), // folder uid
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "sharedfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "sharedfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
allPermissions := make(map[int64]map[string][]string)
|
||||
permissions := make(map[string][]string)
|
||||
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
|
||||
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
|
||||
allPermissions[1] = permissions
|
||||
// "Permissions" is where we store the uid of dashboards shared with the user
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
|
||||
@@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
|
||||
// first call gets all dashboards user has permission for
|
||||
firstCall := mockClient.MockCalls[0]
|
||||
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
|
||||
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
|
||||
// verify federated field is set to include folders
|
||||
assert.NotNil(t, firstCall.Federated)
|
||||
assert.Equal(t, 1, len(firstCall.Federated))
|
||||
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
|
||||
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
|
||||
// second call gets folders associated with the previous dashboards
|
||||
secondCall := mockClient.MockCalls[1]
|
||||
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
|
||||
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
|
||||
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
|
||||
// permission to read
|
||||
thirdCall := mockClient.MockCalls[2]
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
|
||||
|
||||
resp := rr.Result()
|
||||
defer func() {
|
||||
|
||||
@@ -133,7 +133,11 @@ type FeatureFlag struct {
|
||||
Stage FeatureFlagStage `json:"stage,omitempty"`
|
||||
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
|
||||
|
||||
// CEL-GO expression. Using the value "true" will mean this is on by default
|
||||
// Expression defined by the feature_toggles configuration.
|
||||
// Supports multiple types including boolean, string, integer, float,
|
||||
// and structured values following the OpenFeature specification.
|
||||
// Using the value "true" means the feature flag is enabled by default,
|
||||
// Using the value "1.0" means the default value of the feature flag is 1.0
|
||||
Expression string `json:"expression,omitempty"`
|
||||
|
||||
// Special behavior properties
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
@@ -26,7 +27,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]memprovider.InMemoryFlag
|
||||
// TargetingKey is used for evaluation context
|
||||
TargetingKey string
|
||||
// ContextAttrs are additional attributes for evaluation context
|
||||
@@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
func createProvider(
|
||||
providerType string,
|
||||
u *url.URL,
|
||||
staticFlags map[string]bool,
|
||||
staticFlags map[string]memprovider.InMemoryFlag,
|
||||
httpClient *http.Client,
|
||||
) (openfeature.FeatureProvider, error) {
|
||||
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
|
||||
@@ -117,7 +118,7 @@ func createProvider(
|
||||
}
|
||||
}
|
||||
|
||||
return newStaticProvider(staticFlags)
|
||||
return newStaticProvider(staticFlags, standardFeatureFlags)
|
||||
}
|
||||
|
||||
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
|
||||
|
||||
@@ -1031,13 +1031,6 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "exploreLogsLimitedTimeRange",
|
||||
Description: "Used in Logs Drilldown to limit the time range",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "appPlatformGrpcClientAuth",
|
||||
Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens",
|
||||
|
||||
@@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
|
||||
}
|
||||
mgmt.warnings[key] = "unknown flag in config"
|
||||
}
|
||||
mgmt.startup[key] = val
|
||||
|
||||
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
|
||||
}
|
||||
|
||||
// update the values
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
|
||||
@@ -28,37 +33,21 @@ 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]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
|
||||
|
||||
// Parse and add standard flags
|
||||
for _, flag := range standardFlags {
|
||||
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
|
||||
}
|
||||
|
||||
flags[flag.Name] = inMemFlag
|
||||
}
|
||||
|
||||
// Add flags from config.ini file
|
||||
for name, value := range confFlags {
|
||||
flags[name] = createInMemoryFlag(name, value)
|
||||
}
|
||||
|
||||
// Add standard flags
|
||||
for _, flag := range standardFeatureFlags {
|
||||
if _, exists := flags[flag.Name]; !exists {
|
||||
enabled := flag.Expression == "true"
|
||||
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
|
||||
}
|
||||
}
|
||||
maps.Copy(flags, confFlags)
|
||||
|
||||
return newInMemoryBulkProvider(flags), nil
|
||||
}
|
||||
|
||||
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
|
||||
variant := "disabled"
|
||||
if enabled {
|
||||
variant = "enabled"
|
||||
}
|
||||
|
||||
return memprovider.InMemoryFlag{
|
||||
Key: name,
|
||||
DefaultVariant: variant,
|
||||
Variants: map[string]interface{}{
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -93,3 +94,144 @@ ABCD = true
|
||||
enabledFeatureManager := mgr.GetEnabled(ctx)
|
||||
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
|
||||
}
|
||||
|
||||
func Test_StaticProvider_TypedFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
flags FeatureFlag
|
||||
defaultValue any
|
||||
expectedValue any
|
||||
}{
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "true",
|
||||
},
|
||||
defaultValue: false,
|
||||
expectedValue: true,
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "1.0",
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expectedValue: 1.0,
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "blue",
|
||||
},
|
||||
defaultValue: "red",
|
||||
expectedValue: "blue",
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: "1",
|
||||
},
|
||||
defaultValue: int64(0),
|
||||
expectedValue: int64(1),
|
||||
},
|
||||
{
|
||||
flags: FeatureFlag{
|
||||
Name: "Flag",
|
||||
Expression: `{ "foo": "bar" }`,
|
||||
},
|
||||
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.expectedValue.(type) {
|
||||
case bool:
|
||||
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
|
||||
case float64:
|
||||
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 int64:
|
||||
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
|
||||
case map[string]any:
|
||||
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
|
||||
originalValue string
|
||||
configValue any
|
||||
}{
|
||||
{
|
||||
name: "bool",
|
||||
originalValue: "false",
|
||||
configValue: true,
|
||||
},
|
||||
{
|
||||
name: "int",
|
||||
originalValue: "0",
|
||||
configValue: int64(1),
|
||||
},
|
||||
{
|
||||
name: "float",
|
||||
originalValue: "0.0",
|
||||
configValue: 1.0,
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
originalValue: "foo",
|
||||
configValue: "bar",
|
||||
},
|
||||
{
|
||||
name: "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.configValue.(type) {
|
||||
case bool:
|
||||
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
|
||||
case float64:
|
||||
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 int64:
|
||||
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
|
||||
case map[string]any:
|
||||
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
|
||||
originalValue string
|
||||
configValue any
|
||||
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
|
||||
orig := FeatureFlag{
|
||||
Name: tt.name,
|
||||
Expression: tt.originalValue,
|
||||
}
|
||||
|
||||
config := map[string]memprovider.InMemoryFlag{
|
||||
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
|
||||
}
|
||||
|
||||
return config, []FeatureFlag{orig}
|
||||
}
|
||||
|
||||
Generated
-1
@@ -142,7 +142,6 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
|
||||
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
|
||||
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
|
||||
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
|
||||
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
|
||||
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
|
||||
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
|
||||
|
+2
-1
@@ -1382,7 +1382,8 @@
|
||||
"metadata": {
|
||||
"name": "exploreLogsLimitedTimeRange",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-08-29T13:55:59Z"
|
||||
"creationTimestamp": "2024-08-29T13:55:59Z",
|
||||
"deletionTimestamp": "2026-01-12T22:18:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Used in Logs Drilldown to limit the time range",
|
||||
|
||||
@@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) {
|
||||
if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" {
|
||||
t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name)
|
||||
}
|
||||
if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" {
|
||||
t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name)
|
||||
}
|
||||
// Check camel case names
|
||||
if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] {
|
||||
invalidNames = append(invalidNames, flag.Name)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -378,8 +379,10 @@ 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]memprovider.InMemoryFlag{
|
||||
featuremgmt.FlagPluginsAutoUpdate: {
|
||||
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// DefaultVariantName a placeholder name for config-based Feature Flags
|
||||
const DefaultVariantName = "default"
|
||||
|
||||
// Deprecated: should use `featuremgmt.FeatureToggles`
|
||||
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
|
||||
section := iniFile.Section("feature_toggles")
|
||||
@@ -15,18 +22,27 @@ 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
|
||||
}
|
||||
|
||||
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
|
||||
return value && ok
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
|
||||
featureToggles := make(map[string]bool, 10)
|
||||
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
|
||||
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
|
||||
|
||||
// parse the comma separated list in `enable`.
|
||||
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
|
||||
for _, feature := range util.SplitString(featuresTogglesStr) {
|
||||
featureToggles[feature] = true
|
||||
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
|
||||
}
|
||||
|
||||
// read all other settings under [feature_toggles]. If a toggle is
|
||||
@@ -36,7 +52,7 @@ 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
|
||||
}
|
||||
@@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
|
||||
}
|
||||
return featureToggles, nil
|
||||
}
|
||||
|
||||
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
|
||||
var structure map[string]any
|
||||
|
||||
if integer, err := strconv.Atoi(value); err == nil {
|
||||
return NewInMemoryFlag(name, integer), nil
|
||||
}
|
||||
if float, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return NewInMemoryFlag(name, float), nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &structure); err == nil {
|
||||
return NewInMemoryFlag(name, structure), nil
|
||||
}
|
||||
if boolean, err := strconv.ParseBool(value); err == nil {
|
||||
return NewInMemoryFlag(name, boolean), nil
|
||||
}
|
||||
|
||||
return NewInMemoryFlag(name, value), nil
|
||||
}
|
||||
|
||||
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
|
||||
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
|
||||
}
|
||||
|
||||
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
|
||||
var res = map[string]string{}
|
||||
for k, v := range m {
|
||||
res[k] = serializeFlagValue(v)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
|
||||
value := flag.Variants[flag.DefaultVariant]
|
||||
|
||||
switch castedValue := value.(type) {
|
||||
case bool:
|
||||
return strconv.FormatBool(castedValue)
|
||||
case int64:
|
||||
return strconv.FormatInt(castedValue, 10)
|
||||
case float64:
|
||||
// handle cases with a single or no zeros after the decimal point
|
||||
if math.Trunc(castedValue) == castedValue {
|
||||
return strconv.FormatFloat(castedValue, 'f', 1, 64)
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(castedValue, 'g', -1, 64)
|
||||
case string:
|
||||
return castedValue
|
||||
default:
|
||||
val, _ := json.Marshal(value)
|
||||
return string(val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
@@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
conf map[string]string
|
||||
err error
|
||||
expectedToggles map[string]bool
|
||||
expectedToggles map[string]memprovider.InMemoryFlag
|
||||
}{
|
||||
{
|
||||
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]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", true),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) {
|
||||
"enable": "feature1,feature2",
|
||||
"feature3": "true",
|
||||
},
|
||||
expectedToggles: map[string]bool{
|
||||
"feature1": true,
|
||||
"feature2": true,
|
||||
"feature3": true,
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", true),
|
||||
"feature3": NewInMemoryFlag("feature3", true),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) {
|
||||
"enable": "feature1,feature2",
|
||||
"feature2": "false",
|
||||
},
|
||||
expectedToggles: map[string]bool{
|
||||
"feature1": true,
|
||||
"feature2": false,
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", true),
|
||||
"feature2": NewInMemoryFlag("feature2", false),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid boolean value should return syntax error",
|
||||
name: "feature flags of different types are handled correctly",
|
||||
conf: map[string]string{
|
||||
"enable": "feature1,feature2",
|
||||
"feature2": "invalid",
|
||||
"feature1": "1", "feature2": "1.0",
|
||||
"feature3": `{"foo":"bar"}`, "feature4": "bar",
|
||||
"feature5": "t", "feature6": "T",
|
||||
},
|
||||
expectedToggles: map[string]memprovider.InMemoryFlag{
|
||||
"feature1": NewInMemoryFlag("feature1", 1),
|
||||
"feature2": NewInMemoryFlag("feature2", 1.0),
|
||||
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
|
||||
"feature4": NewInMemoryFlag("feature4", "bar"),
|
||||
"feature5": NewInMemoryFlag("feature5", true),
|
||||
"feature6": NewInMemoryFlag("feature6", true),
|
||||
},
|
||||
expectedToggles: map[string]bool{},
|
||||
err: strconv.ErrSyntax,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) {
|
||||
}
|
||||
|
||||
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
|
||||
require.ErrorIs(t, err, tc.err)
|
||||
require.NoError(t, err)
|
||||
|
||||
if err == nil {
|
||||
for k, v := range featureToggles {
|
||||
require.Equal(t, tc.expectedToggles[k], v, tc.name)
|
||||
}
|
||||
for k, v := range featureToggles {
|
||||
toggle := tc.expectedToggles[k]
|
||||
require.Equal(t, toggle, v, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValueSerialization(t *testing.T) {
|
||||
testCases := []memprovider.InMemoryFlag{
|
||||
NewInMemoryFlag("int", 1),
|
||||
NewInMemoryFlag("1.0f", 1.0),
|
||||
NewInMemoryFlag("1.01f", 1.01),
|
||||
NewInMemoryFlag("1.10f", 1.10),
|
||||
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
|
||||
NewInMemoryFlag("string", "bar"),
|
||||
NewInMemoryFlag("true", true),
|
||||
NewInMemoryFlag("false", false),
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
|
||||
|
||||
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
|
||||
assert.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(tt, deserialized); diff != "" {
|
||||
t.Errorf("(-want, +got) = %v", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
|
||||
|
||||
kvbackend, db := newKvBackend(t.Context())
|
||||
sqlbackend, _ := newSqlBackend(t.Context())
|
||||
|
||||
// Skip on SQLite due to concurrency limitations
|
||||
if db.DriverName() == "sqlite3" {
|
||||
t.Skip("Skipping concurrent operations stress test on SQLite")
|
||||
}
|
||||
|
||||
tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db)
|
||||
})
|
||||
}
|
||||
@@ -686,11 +692,6 @@ func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource
|
||||
|
||||
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
|
||||
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
|
||||
// Skip on SQLite due to concurrency limitations
|
||||
if db.DriverName() == "sqlite3" {
|
||||
t.Skip("Skipping concurrent operations stress test on SQLite")
|
||||
}
|
||||
|
||||
ctx := testutil.NewDefaultTestContext(t)
|
||||
|
||||
// Create storage servers from both backends
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
|
||||
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -64,10 +63,7 @@ func (s *Service) runMetricsStream(ctx context.Context, req *backend.RunStreamRe
|
||||
qrr.Start = uint64(backendQuery.TimeRange.From.UnixNano())
|
||||
qrr.End = uint64(backendQuery.TimeRange.To.UnixNano())
|
||||
|
||||
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
|
||||
// changes or updates, so we have to get it from context.
|
||||
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
|
||||
ctx = stream_utils.AppendHeadersToOutgoingContext(ctx, req)
|
||||
|
||||
if isInstantQuery(tempoQuery.MetricsQueryType) {
|
||||
instantQuery := &tempopb.QueryInstantRequest{
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
|
||||
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
|
||||
"github.com/grafana/tempo/pkg/tempopb"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -62,10 +61,7 @@ func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamReq
|
||||
sr.Start = uint32(backendQuery.TimeRange.From.Unix())
|
||||
sr.End = uint32(backendQuery.TimeRange.To.Unix())
|
||||
|
||||
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
|
||||
// changes or updates, so we have to get it from context.
|
||||
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
|
||||
ctx = stream_utils.AppendHeadersToOutgoingContext(ctx, req)
|
||||
|
||||
stream, err := datasource.StreamingClient.Search(ctx, sr)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
|
||||
)
|
||||
|
||||
func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
|
||||
@@ -39,11 +40,18 @@ func (s *Service) PublishStream(_ context.Context, _ *backend.PublishStreamReque
|
||||
|
||||
func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error {
|
||||
s.logger.Debug("New stream call", "path", request.Path)
|
||||
tempoDatasource, err := s.getDSInfo(ctx, request.PluginContext)
|
||||
tempoDatasource, dsInfoErr := s.getDSInfo(ctx, request.PluginContext)
|
||||
|
||||
// get incoming and team http headers and append to stream request.
|
||||
headers, err := stream_utils.SetHeadersFromIncomingContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Headers = headers
|
||||
|
||||
if strings.HasPrefix(request.Path, SearchPathPrefix) {
|
||||
if err != nil {
|
||||
return backend.DownstreamErrorf("failed to get datasource information: %w", err)
|
||||
if dsInfoErr != nil {
|
||||
return backend.DownstreamErrorf("failed to get datasource information: %w", dsInfoErr)
|
||||
}
|
||||
if err = s.runSearchStream(ctx, request, sender, tempoDatasource); err != nil {
|
||||
return sendError(err, sender)
|
||||
@@ -52,8 +60,8 @@ func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamReque
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(request.Path, MetricsPathPrefix) {
|
||||
if err != nil {
|
||||
return backend.DownstreamErrorf("failed to get datasource information: %w", err)
|
||||
if dsInfoErr != nil {
|
||||
return backend.DownstreamErrorf("failed to get datasource information: %w", dsInfoErr)
|
||||
}
|
||||
if err = s.runMetricsStream(ctx, request, sender, tempoDatasource); err != nil {
|
||||
return sendError(err, sender)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package stream_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Appends incoming request headers to the outgoing context to make sure none are lost when we make the request to tempo.
|
||||
func AppendHeadersToOutgoingContext(ctx context.Context, req *backend.RunStreamRequest) context.Context {
|
||||
// append all incoming headers
|
||||
for key, value := range req.Headers {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, key, value)
|
||||
}
|
||||
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
|
||||
// changes or updates, so we have to get it from context.
|
||||
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
|
||||
return ctx
|
||||
}
|
||||
|
||||
// When we receive a new query request we should make sure that all incoming HTTP headers are being forwarding to the grpc stream request
|
||||
// this is to make sure that no headers are lost when we make the actual call to Tempo later on.
|
||||
func SetHeadersFromIncomingContext(ctx context.Context) (map[string]string, error) {
|
||||
// get the plugin from context
|
||||
plugin := backend.PluginConfigFromContext(ctx)
|
||||
|
||||
// get the HTTP headers
|
||||
teamHeaders, error := getTeamHTTPHeaders(plugin)
|
||||
if error != nil {
|
||||
return nil, error
|
||||
}
|
||||
|
||||
// get the rest of the incoming headers
|
||||
headers, err := getClientOptionsHeaders(ctx, plugin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range teamHeaders {
|
||||
headers[key] = value
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func getTeamHTTPHeaders(plugin backend.PluginContext) (map[string]string, error) {
|
||||
headers := map[string]string{}
|
||||
// Grab the JSON data from the datasource instance settings
|
||||
jsonData := plugin.DataSourceInstanceSettings.JSONData
|
||||
var data map[string]interface{}
|
||||
err := json.Unmarshal(jsonData, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fetch team http headers
|
||||
if teamHttpHeaders, ok := data["teamHttpHeaders"]; ok {
|
||||
// team headers have the following structure
|
||||
// headers: [<team_id>: [{header: <header_name>, value: <header_value>}]]
|
||||
// header_value is whatever the user has set under LBAC permissions for their given rule.
|
||||
if lbacHeaders, ok := teamHttpHeaders.(map[string]interface{})["headers"]; ok {
|
||||
headerMap := lbacHeaders.(map[string]interface{})
|
||||
labelPolicyKey, labelPolicyValue := getLabelPolicyKeyValue(headerMap)
|
||||
|
||||
if labelPolicyKey != "" && labelPolicyValue != "" {
|
||||
headers[labelPolicyKey] = labelPolicyValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func getLabelPolicyKeyValue(headerWithRules map[string]interface{}) (string, string) {
|
||||
labelPolicyKey := ""
|
||||
labelPolicyValue := ""
|
||||
// we go through each teams' rule and ignoring the team, go through their set rules and prepare them to be all appended for the X-Prom-Label-Policy header value
|
||||
// the result will be a comma separated list of the rules:
|
||||
// "<rule_num>:<rule_value>, <rule_num>:<rule_value>"
|
||||
for _, accessRuleValue := range headerWithRules {
|
||||
rules := accessRuleValue.([]interface{})
|
||||
for _, accessRule := range rules {
|
||||
header := accessRule.(map[string]interface{})
|
||||
for key, value := range header {
|
||||
// for now, team headers only contain a single header key value, but in case in the future more are introduced, we make sure we only set the one we care about.
|
||||
if key == "header" && value == "X-Prom-Label-Policy" {
|
||||
labelPolicyKey = value.(string)
|
||||
continue
|
||||
}
|
||||
if key == "value" {
|
||||
if valueStr, ok := value.(string); ok {
|
||||
if labelPolicyValue == "" {
|
||||
labelPolicyValue = valueStr
|
||||
} else {
|
||||
labelPolicyValue += "," + valueStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return labelPolicyKey, labelPolicyValue
|
||||
}
|
||||
|
||||
func getClientOptionsHeaders(ctx context.Context, plugin backend.PluginContext) (map[string]string, error) {
|
||||
headers := map[string]string{}
|
||||
opts, err := plugin.DataSourceInstanceSettings.HTTPClientOptions(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HTTP client options: %w", err)
|
||||
}
|
||||
|
||||
for name, values := range opts.Header {
|
||||
for _, value := range values {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package stream_utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/useragent"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func TestAppendHeadersToOutgoingContext_AppendsHeadersAndUserAgent(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
ua, err := useragent.New("10.0.0", "linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
ctx = backend.WithUserAgent(ctx, ua)
|
||||
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("Existing", "one"))
|
||||
|
||||
req := &backend.RunStreamRequest{
|
||||
Headers: map[string]string{
|
||||
"X-Test": "value",
|
||||
},
|
||||
}
|
||||
|
||||
out := AppendHeadersToOutgoingContext(ctx, req)
|
||||
outgoingMD, ok := metadata.FromOutgoingContext(out)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"value"}, outgoingMD.Get("x-test"))
|
||||
assert.Equal(t, []string{ua.String()}, outgoingMD.Get("user-agent"))
|
||||
assert.Equal(t, []string{"one"}, outgoingMD.Get("existing"))
|
||||
}
|
||||
|
||||
func TestSetHeadersFromIncomingContext_MergesTeamAndClientHeaders(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"teamHttpHeaders": {
|
||||
"headers": {
|
||||
"101": [
|
||||
{"header": "X-Prom-Label-Policy", "value": "1:team-value"},
|
||||
{"header": "X-Prom-Label-Policy", "value": "2:team-wins"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"httpHeaderName1": "X-Client",
|
||||
"httpHeaderName2": "X-Shared"
|
||||
}`)
|
||||
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: jsonData,
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value",
|
||||
"httpHeaderValue2": "client-overridden",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
|
||||
headers, err := SetHeadersFromIncomingContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := map[string]string{
|
||||
"X-Client": "client-value",
|
||||
"X-Prom-Label-Policy": "1:team-value,2:team-wins",
|
||||
"X-Shared": "client-overridden",
|
||||
}
|
||||
assert.Equal(t, expected, headers)
|
||||
}
|
||||
|
||||
func TestGetTeamHTTPHeaders_NoTeamHeaders(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte(`{"httpHeaderName1": "X-Client"}`),
|
||||
},
|
||||
}
|
||||
|
||||
headers, err := getTeamHTTPHeaders(pluginCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, headers)
|
||||
}
|
||||
|
||||
func TestGetTeamHTTPHeaders_LabelPolicyValue(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte(`{
|
||||
"teamHttpHeaders": {
|
||||
"headers": {
|
||||
"101": [
|
||||
{"header": "X-Prom-Label-Policy", "value": "1:team-value"},
|
||||
{"header": "X-Prom-Label-Policy", "value": "2:team-wins"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
headers, err := getTeamHTTPHeaders(pluginCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
"X-Prom-Label-Policy": "1:team-value,2:team-wins",
|
||||
}, headers)
|
||||
}
|
||||
|
||||
func TestGetLabelPolicyKeyValue_AppendsValues(t *testing.T) {
|
||||
headerWithRules := map[string]interface{}{
|
||||
"101": []interface{}{
|
||||
map[string]interface{}{
|
||||
"header": "X-Prom-Label-Policy",
|
||||
"value": "1:alpha",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"header": "X-Prom-Label-Policy",
|
||||
"value": "2:beta",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
key, value := getLabelPolicyKeyValue(headerWithRules)
|
||||
assert.Equal(t, "X-Prom-Label-Policy", key)
|
||||
assert.Equal(t, "1:alpha,2:beta", value)
|
||||
}
|
||||
|
||||
func TestGetClientOptionsHeaders_ParsesHeaders(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte(`{"httpHeaderName1": "X-Client"}`),
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "client-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
headers, err := getClientOptionsHeaders(context.Background(), pluginCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"X-Client": "client-value"}, headers)
|
||||
}
|
||||
|
||||
func TestGetClientOptionsHeaders_InvalidJSON(t *testing.T) {
|
||||
pluginCtx := backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
JSONData: []byte("{"),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := getClientOptionsHeaders(context.Background(), pluginCtx)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -15,7 +15,10 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
// make sure we don't leak goroutines after tests in this package have
|
||||
// finished, which means we haven't leaked contexts either
|
||||
goleak.VerifyTestMain(m)
|
||||
// (Except for goroutines running specific functions. If possible we should fix this.)
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestTestContextFunc(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user