Merge branch 'main' into kristina/static-transform-refIds

This commit is contained in:
Kristina Durivage
2025-09-25 13:12:03 -05:00
183 changed files with 3636 additions and 3541 deletions
+1
View File
@@ -1262,6 +1262,7 @@ embed.go @grafana/grafana-as-code
/.github/workflows/changelog.yml @zserge
/.github/workflows/shellcheck.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/release-build.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/cleanup-branches.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/publish-artifact.yml @grafana/grafana-developer-enablement-squad
/.github/actions/changelog @zserge
/.github/workflows/swagger-gen.yml @grafana/grafana-backend-group
+18
View File
@@ -0,0 +1,18 @@
name: Clean up orphaned branches
on:
workflow_dispatch:
schedule:
- cron: "0 9 * * 1"
jobs:
cleanup-branches:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- uses: actions/checkout@v5
- uses: grafana/shared-workflows/actions/cleanup-branches@cleanup-branches/v1.0.0
with:
dry-run: true
max-date: "1 month ago"
@@ -132,6 +132,7 @@ const (
EnricherTypeAsserts EnricherType = "asserts"
EnricherTypeExplain EnricherType = "explain"
EnricherTypeLoop EnricherType = "loop"
EnricherTypeAssistant EnricherType = "assistant"
)
// EnricherConfig is a discriminated union of enricher configurations.
@@ -145,6 +146,7 @@ type EnricherConfig struct {
Asserts *AssertsEnricher `json:"asserts,omitempty" yaml:"asserts,omitempty" jsonschema:"description=Asserts enricher settings"`
Explain *ExplainEnricher `json:"explain,omitempty" yaml:"explain,omitempty" jsonschema:"description=Explain enricher settings"`
Loop *LoopEnricher `json:"loop,omitempty" yaml:"loop,omitempty" jsonschema:"description=Loop enricher settings"`
Assistant *AssistantEnricher `json:"assistant,omitempty" yaml:"assistant,omitempty" jsonschema:"description=Assistant enricher settings"`
}
// AssignEnricher configures an enricher which assigns annotations.
@@ -224,10 +226,15 @@ type AssertsEnricher struct {
// ExplainEnricher uses LLM to generate explanations for alerts.
type ExplainEnricher struct {
Annotation string `json:"annotation" yaml:"annotation" jsonschema:"description=Annotation name to set the explanation in, by default 'ai_explanation'"`
Annotation string `json:"annotation" yaml:"annotation" jsonschema:"description=Annotation name to set the explanation in, by default '__enriched_ai_explanation'"`
}
// LoopEnricher configures an enricher which calls into Loop.
type LoopEnricher struct {
// In the future, there may be configuration options.
}
// AssistantEnricher configures an enricher which calls into Assistant.
type AssistantEnricher struct {
// In the future, there may be configuration options.
}
@@ -183,6 +183,22 @@ func (in *Assignment) DeepCopy() *Assignment {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AssistantEnricher) DeepCopyInto(out *AssistantEnricher) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AssistantEnricher.
func (in *AssistantEnricher) DeepCopy() *AssistantEnricher {
if in == nil {
return nil
}
out := new(AssistantEnricher)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Condition) DeepCopyInto(out *Condition) {
*out = *in
@@ -309,6 +325,11 @@ func (in *EnricherConfig) DeepCopyInto(out *EnricherConfig) {
*out = new(LoopEnricher)
**out = **in
}
if in.Assistant != nil {
in, out := &in.Assistant, &out.Assistant
*out = new(AssistantEnricher)
**out = **in
}
return
}
@@ -21,6 +21,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssertsEnricher(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssignEnricher(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Assignment": schema_pkg_apis_alertenrichment_v1beta1_Assignment(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssistantEnricher(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Condition": schema_pkg_apis_alertenrichment_v1beta1_Condition(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Conditional": schema_pkg_apis_alertenrichment_v1beta1_Conditional(ref),
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher": schema_pkg_apis_alertenrichment_v1beta1_DataSourceEnricher(ref),
@@ -325,6 +326,17 @@ func schema_pkg_apis_alertenrichment_v1beta1_Assignment(ref common.ReferenceCall
}
}
func schema_pkg_apis_alertenrichment_v1beta1_AssistantEnricher(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "AssistantEnricher configures an enricher which calls into Assistant.",
Type: []string{"object"},
},
},
}
}
func schema_pkg_apis_alertenrichment_v1beta1_Condition(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -467,11 +479,11 @@ func schema_pkg_apis_alertenrichment_v1beta1_EnricherConfig(ref common.Reference
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"asserts\"`\n - `\"assign\"`\n - `\"dsquery\"`\n - `\"explain\"`\n - `\"external\"`\n - `\"loop\"`\n - `\"sift\"`",
Description: "Possible enum values:\n - `\"asserts\"`\n - `\"assign\"`\n - `\"assistant\"`\n - `\"dsquery\"`\n - `\"explain\"`\n - `\"external\"`\n - `\"loop\"`\n - `\"sift\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"asserts", "assign", "dsquery", "explain", "external", "loop", "sift"},
Enum: []interface{}{"asserts", "assign", "assistant", "dsquery", "explain", "external", "loop", "sift"},
},
},
"assign": {
@@ -509,12 +521,17 @@ func schema_pkg_apis_alertenrichment_v1beta1_EnricherConfig(ref common.Reference
Ref: ref("github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher"),
},
},
"assistant": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher"),
},
},
},
Required: []string{"type"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExplainEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExternalEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.SiftEnricher"},
"github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExplainEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExternalEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.SiftEnricher"},
}
}
@@ -18,7 +18,7 @@ const (
)
var RepositoryResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"repositories", "repository", "Repositories",
"repositories", "repository", "Repository",
func() runtime.Object { return &Repository{} }, // newObj
func() runtime.Object { return &RepositoryList{} }, // newList
utils.TableColumns{ // Returned by `kubectl get`. Doesn't affect disk storage.
@@ -159,6 +159,66 @@ func (r *Repository) Branch() string {
return ""
}
// URL returns the URL for git-based repositories
// or an empty string for local repositories
func (r *Repository) URL() string {
if !r.Spec.Type.IsGit() {
return ""
}
switch r.Spec.Type {
case GitHubRepositoryType:
if r.Spec.GitHub != nil {
return r.Spec.GitHub.URL
}
case GitRepositoryType:
if r.Spec.Git != nil {
return r.Spec.Git.URL
}
case BitbucketRepositoryType:
if r.Spec.Bitbucket != nil {
return r.Spec.Bitbucket.URL
}
case GitLabRepositoryType:
if r.Spec.GitLab != nil {
return r.Spec.GitLab.URL
}
default:
return ""
}
return ""
}
func (r *Repository) Path() string {
switch r.Spec.Type {
case GitHubRepositoryType:
if r.Spec.GitHub != nil {
return r.Spec.GitHub.Path
}
case GitRepositoryType:
if r.Spec.Git != nil {
return r.Spec.Git.Path
}
case BitbucketRepositoryType:
if r.Spec.Bitbucket != nil {
return r.Spec.Bitbucket.Path
}
case GitLabRepositoryType:
if r.Spec.GitLab != nil {
return r.Spec.GitLab.Path
}
case LocalRepositoryType:
if r.Spec.Local != nil {
return r.Spec.Local.Path
}
default:
return ""
}
return ""
}
type RepositorySpec struct {
// The repository display name (shown in the UI)
Title string `json:"title"`
+30 -1
View File
@@ -12,7 +12,16 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// RepositoryValidator interface for validating repositories against existing ones
type RepositoryValidator interface {
VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error
}
func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
return TestRepositoryWithValidator(ctx, repo, nil)
}
func TestRepositoryWithValidator(ctx context.Context, repo Repository, validator RepositoryValidator) (*provisioning.TestResults, error) {
errors := ValidateRepository(repo)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
@@ -30,7 +39,27 @@ func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestRes
return rsp, nil
}
return repo.Test(ctx)
rsp, err := repo.Test(ctx)
if err != nil {
return nil, err
}
if rsp.Success && validator != nil {
cfg := repo.Config()
if validationErr := validator.VerifyAgainstExistingRepositories(ctx, cfg); validationErr != nil {
rsp = &provisioning.TestResults{
Success: false,
Code: http.StatusUnprocessableEntity,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseType(validationErr.Type),
Field: validationErr.Field,
Detail: validationErr.Detail,
}},
}
}
}
return rsp, nil
}
func ValidateRepository(repo Repository) field.ErrorList {
+7 -10
View File
@@ -64,13 +64,6 @@ for dir in $@; do
fi
done
volume_files=$($blocks_dir/**/$compose_volume_section_create_flag)
if [[ ${#volume_files[@]} -ne 0 ]]; then
echo "Adding volume section to $compose_file"
cat $compose_volume_section_file >> $compose_file
echo "" >> $compose_file
for dir in $@; do
current_dir=$blocks_dir/$dir
if [ ! -d "$current_dir" ]; then
@@ -80,11 +73,15 @@ if [[ ${#volume_files[@]} -ne 0 ]]; then
if [ -f $current_dir/$compose_volume_section_create_flag ]; then
if [ -z ${inserted_volume_section_start+x} ]; then
echo "Adding volume section to $compose_file"
cat $compose_volume_section_file >> $compose_file
echo "" >> $compose_file
inserted_volume_section_start=true
fi
echo "Adding volume for $current_dir to $compose_file"
echo " $dir-data-volume:" >> $compose_file
echo "" >> $compose_file
fi
done
cat $compose_file
fi
@@ -25,6 +25,8 @@ refs:
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/#grafana-adaptive-metrics-action-definitions
cloud-access-policies-action-definitions:
- pattern: /docs/grafana/
destination: docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
rbac-role-definitions:
@@ -37,7 +37,7 @@ Use these steps to migrate resources between environments:
{{< admonition type="note" >}}
Resources are pulled and pushed from the `./resources` directory by default.
This directory can be configured with the `--directory`/`-d` flags.
This can be configured with the `-p, --path` flags to specify custom paths on disk.
{{< /admonition >}}
1. Make changes to dashboards and other resources using the Grafana UI in your **development instance**.
@@ -45,21 +45,21 @@ This directory can be configured with the `--directory`/`-d` flags.
```bash
grafanactl config use-context YOUR_CONTEXT # for example "dev"
grafanactl resources pull -d ./resources/ -o yaml # or json
grafanactl resources pull --path ./resources/ -o yaml # or json
```
1. (Optional) Preview the resources locally before pushing:
```bash
grafanactl config use-context YOUR_CONTEXT # for example "prod"
grafanactl resources serve -d ./resources/
grafanactl resources serve ./resources/
```
1. Switch to the **production instance** and push the resources:
```bash
grafanactl config use-context YOUR_CONTEXT # for example "prod"
grafanactl resources push -d ./resources/
grafanactl resources push -p ./resources/
```
## Back up Grafana resources
@@ -70,7 +70,7 @@ This workflow helps you back up all Grafana resources from one instance and late
```bash
grafanactl config use-context YOUR_CONTEXT # for example "prod"
grafanactl resources pull -d ./resources/ -o yaml # or json
grafanactl resources pull --path ./resources/ -o yaml # or json
```
1. Save the exported resources to version control or cloud storage.
@@ -81,14 +81,14 @@ This workflow helps you back up all Grafana resources from one instance and late
```bash
grafanactl config use-context YOUR_CONTEXT # for example "prod"
grafanactl resources serve -d ./resources/
grafanactl resources serve ./resources/
```
1. To restore the resources later or restore them on another instance, push the saved resources:
```bash
grafanactl config use-context YOUR_CONTEXT # for example "prod"
grafanactl resources push -d ./resources/
grafanactl resources push -p ./resources/
```
## Manage dashboards as code
@@ -114,7 +114,7 @@ With this workflow, you can define and manage dashboards as code, saving them to
```bash
grafanactl config use-context YOUR_CONTEXT # for example "dev"
grafanactl resources push -d ./resources/
grafanactl resources push -p ./resources/
```
## Explore and modify resources from the terminal
@@ -197,7 +197,7 @@ Use this workflow to locate dashboards using a deprecated API version and mark t
playlist.grafana.app v1 playlist
```
1. Find dashboards that are still using an old API version:
1. Find dashboards that are still using a deprecated API version:
```bash
grafanactl resources get dashboards.v1.dashboard.grafana.app
-5
View File
@@ -24,11 +24,6 @@
"count": 1
}
},
"packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"packages/grafana-data/src/dataframe/ArrayDataFrame.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
+1 -1
View File
@@ -101,7 +101,7 @@ require (
github.com/grafana/grafana-aws-sdk v1.2.0 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-google-sdk-go v0.4.1 // @grafana/partner-datasources
github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group
github.com/grafana/grafana-plugin-sdk-go v0.279.0 // @grafana/plugins-platform-backend
github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs
+2 -2
View File
@@ -1615,8 +1615,8 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAc
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8=
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI=
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0/go.mod h1:nOHgq4Oa829qmBKA5KIXw5Ipo3rhLs0d6A8UI9Nw8Zk=
github.com/grafana/grafana-google-sdk-go v0.4.1 h1:QdHmgDzlV3RzBTvIxd+WuxER1+afnFzUmEivbwDo27E=
github.com/grafana/grafana-google-sdk-go v0.4.1/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40=
github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo=
github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
github.com/grafana/grafana-plugin-sdk-go v0.279.0 h1:/KCrsZkj9pEGwIGovqAz1A8rjI2A2YT+ZpvgfZN0LAA=
+2
View File
@@ -1074,6 +1074,8 @@ github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+
github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss=
github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo=
github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo=
github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40=
github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q=
github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI=
@@ -0,0 +1,74 @@
import { RoutingTree } from '../../api/v0alpha1/api.gen';
import { LabelMatcherFactory, RouteFactory } from '../../api/v0alpha1/mocks/fakes/Routes';
import { Label } from '../../matchers/types';
import { matchInstancesToRouteTrees } from './useMatchPolicies';
describe('matchInstancesToRouteTrees', () => {
it('should return root route when child routes do not match instances', () => {
const route = RouteFactory.build({
matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })],
receiver: 'web-team',
});
const treeName = 'test-tree';
const trees: RoutingTree[] = [
{
kind: 'RoutingTree',
metadata: { name: treeName },
spec: {
defaults: {
receiver: 'receiver 1',
},
routes: [route],
},
status: {},
},
];
const instanceLabels: Label[] = [['service', 'api']];
const instances: Label[][] = [instanceLabels]; // Different service - should not match
const result = matchInstancesToRouteTrees(trees, instances);
expect(result).toHaveLength(1);
expect(result[0].labels).toBe(instanceLabels);
expect(result[0].matchedRoutes).toHaveLength(1);
// The root route should match as it's a catch-all
expect(result[0].matchedRoutes[0].route.receiver).toBe('receiver 1');
expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName);
});
it('should return matched routes when trees match instances', () => {
const route = RouteFactory.build({
matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })],
receiver: 'web-team',
});
const treeName = 'test-tree';
const trees: RoutingTree[] = [
{
kind: 'RoutingTree',
metadata: { name: treeName },
spec: {
defaults: {
receiver: 'receiver 1',
},
routes: [route],
},
status: {},
},
];
const instanceLabels: Label[] = [['service', 'web']];
const instances: Label[][] = [instanceLabels];
const result = matchInstancesToRouteTrees(trees, instances);
expect(result).toHaveLength(1);
expect(result[0].labels).toBe(instanceLabels);
expect(result[0].matchedRoutes.length).toBeGreaterThan(0);
expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName);
expect(result[0].matchedRoutes[0].matchDetails.labels).toBe(instanceLabels);
});
});
@@ -4,7 +4,7 @@ import { RoutingTree, alertingAPI } from '../../api/v0alpha1/api.gen';
import { Label } from '../../matchers/types';
import { USER_DEFINED_TREE_NAME } from '../consts';
import { Route, RouteWithID } from '../types';
import { RouteMatchResult, convertRoutingTreeToRoute, matchAlertInstancesToPolicyTree } from '../utils';
import { RouteMatchResult, TreeMatch, convertRoutingTreeToRoute, matchInstancesToRoute } from '../utils';
export type RouteMatch = {
route: Route;
@@ -32,11 +32,10 @@ export type InstanceMatchResult = {
* 2. Compute the inherited properties for each node in the tree
* 3. Find routes within each tree that match the given set of labels
*
* @returns An object containing a `matchInstancesToPolicies` function that takes alert instances
* @returns An object containing a `matchInstancesToRoutingTrees` function that takes alert instances
* and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details
*/
export function useMatchAlertInstancesToNotificationPolicies() {
// fetch the routing trees from the API
export function useMatchInstancesToRouteTrees() {
const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery(
{},
{
@@ -45,52 +44,52 @@ export function useMatchAlertInstancesToNotificationPolicies() {
}
);
const matchInstancesToPolicies = useCallback(
(instances: Label[][]): InstanceMatchResult[] => {
if (!data) {
return [];
}
// the routing trees are returned as an array of items because there can be several
const trees = data.items;
return instances.map<InstanceMatchResult>((labels) => {
// Collect all matched routes from all trees
const allMatchedRoutes: RouteMatch[] = [];
// Process each tree for this instance
trees.forEach((tree) => {
const treeName = tree.metadata.name ?? USER_DEFINED_TREE_NAME;
// We have to convert the RoutingTree structure to a Route structure to be able to use the matching functions
const rootRoute = convertRoutingTreeToRoute(tree);
// Match this single instance against the route tree
const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute);
// Process each matched route from the tree
matchedPolicies.forEach((results, route) => {
// For each match result, create a RouteMatch object
results.forEach((matchDetails) => {
allMatchedRoutes.push({
route,
routeTree: {
metadata: { name: treeName },
expandedSpec: expandedTree,
},
matchDetails,
});
});
});
});
return {
labels,
matchedRoutes: allMatchedRoutes,
};
});
},
[data]
const memoizedFunction = useCallback(
(instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances),
[data?.items]
);
return { matchInstancesToPolicies, ...rest };
return {
matchInstancesToRouteTrees: memoizedFunction,
...rest,
};
}
/**
* This function will match a set of labels to multiple routing trees. Assumes a list of routing trees has already been fetched.
*
* Use "useMatchInstancesToRouteTrees" if you want the hook to automatically fetch the latest definition of routing trees.
*/
export function matchInstancesToRouteTrees(trees: RoutingTree[], instances: Label[][]): InstanceMatchResult[] {
// Process each tree and get matches for all instances
const treeMatches = trees.map<TreeMatch>((tree) => {
const rootRoute = convertRoutingTreeToRoute(tree);
return matchInstancesToRoute(rootRoute, instances);
});
// Group results by instance
return instances.map<InstanceMatchResult>((labels) => {
// Collect matches for this specific instance from all trees
const allMatchedRoutes = treeMatches.flatMap(({ expandedTree, matchedPolicies }, index) => {
const tree = trees[index];
return Array.from(matchedPolicies.entries()).flatMap(([route, results]) =>
results
.filter((matchDetails) => matchDetails.labels === labels)
.map((matchDetails) => ({
route,
routeTree: {
metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME },
expandedSpec: expandedTree,
},
matchDetails,
}))
);
});
return {
labels,
matchedRoutes: allMatchedRoutes,
};
});
}
@@ -12,7 +12,7 @@ import {
computeInheritedTree,
findMatchingRoutes,
getInheritedProperties,
matchAlertInstancesToPolicyTree,
matchInstancesToRoute,
} from './utils';
describe('findMatchingRoutes', () => {
@@ -1072,7 +1072,7 @@ describe('matchAlertInstancesToPolicyTree', () => {
], // Should match parent only
];
const result = matchAlertInstancesToPolicyTree(instances, parentRoute);
const result = matchInstancesToRoute(parentRoute, instances);
// Should return expanded tree with identifiers
expect(result.expandedTree).toHaveProperty('id');
@@ -1109,13 +1109,13 @@ describe('matchAlertInstancesToPolicyTree', () => {
});
// Empty instances array
const result1 = matchAlertInstancesToPolicyTree([], route);
const result1 = matchInstancesToRoute(route, []);
expect(result1.expandedTree).toHaveProperty('id');
expect(result1.matchedPolicies.size).toBe(0);
// Instances that don't match
const instances: Label[][] = [[['service', 'api']]];
const result2 = matchAlertInstancesToPolicyTree(instances, route);
const result2 = matchInstancesToRoute(route, instances);
expect(result2.expandedTree).toHaveProperty('id');
expect(result2.matchedPolicies.size).toBe(0);
});
@@ -1,7 +1,7 @@
import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash';
import { RoutingTree, RoutingTreeRoute } from '../api/v0alpha1/api.gen';
import { Label, LabelMatcher } from '../matchers/types';
import { Label } from '../matchers/types';
import { LabelMatchDetails, matchLabels } from '../matchers/utils';
import { Route, RouteWithID } from './types';
@@ -150,6 +150,7 @@ export function addUniqueIdentifier(route: Route): RouteWithID {
};
}
// all policies that were matched of a single tree
export type TreeMatch = {
/* we'll include the entire expanded policy tree for diagnostics */
expandedTree: RouteWithID;
@@ -166,13 +167,13 @@ export type TreeMatch = {
* @param instances - A set of labels for which you want to determine the matching policies
* @param routingTree - A notification policy tree (or subtree)
*/
export function matchAlertInstancesToPolicyTree(instances: Label[][], routingTree: Route): TreeMatch {
export function matchInstancesToRoute(rootRoute: Route, instances: Label[][]): TreeMatch {
// initially empty map of matches policies
const matchedPolicies = new Map();
// compute the entire expanded tree for matching routes and diagnostics
// this will include inherited properties from parent nodes
const expandedTree = addUniqueIdentifier(computeInheritedTree(routingTree));
const expandedTree = addUniqueIdentifier(computeInheritedTree(rootRoute));
// let's first find all matching routes for the provided instances
const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels));
@@ -202,13 +203,6 @@ export function convertRoutingTreeToRoute(routingTree: RoutingTree): Route {
return routes.map(
(route): Route => ({
...route,
matchers: route.matchers?.map(
(matcher): LabelMatcher => ({
...matcher,
// sadly we use type narrowing for this on Route but the codegen has it as a string
type: matcher.type as LabelMatcher['type'],
})
),
routes: route.routes ? convertRoutingTreeRoutes(route.routes) : [],
})
);
+3 -2
View File
@@ -10,14 +10,15 @@ export { getContactPointDescription } from './grafana/contactPoints/utils';
// Notification Policies
export {
useMatchAlertInstancesToNotificationPolicies,
useMatchInstancesToRouteTrees,
matchInstancesToRouteTrees,
type RouteMatch,
type InstanceMatchResult,
} from './grafana/notificationPolicies/hooks/useMatchPolicies';
export {
type TreeMatch,
type RouteMatchResult,
matchAlertInstancesToPolicyTree,
matchInstancesToRoute,
findMatchingRoutes,
getInheritedProperties,
computeInheritedTree,
@@ -7,8 +7,28 @@ export interface ThemeShape {
radius: Radii;
}
interface Radii {
export interface Radii {
/**
* Use for most things (inputs, buttons, cards, panels, etc)
* Same as `md`
*/
default: string;
/**
* Use for most things (inputs, buttons, cards, panels, etc)
* Same as `default`
*/
md: string;
/**
* Use for smaller things like chips, tags and badges
*/
sm: string;
/**
* Use for large things, like modals
*/
lg: string;
/**
* Used to create maximum half circle sides (e.g. for pills)
*/
pill: string;
circle: string;
}
@@ -19,10 +39,13 @@ export interface ThemeShapeInput {
}
export function createShape(options: ThemeShapeInput): ThemeShape {
const baseBorderRadius = options.borderRadius ?? 2;
const baseBorderRadius = options.borderRadius ?? 6;
const radius = {
default: `${baseBorderRadius}px`,
md: `${baseBorderRadius}px`,
sm: `${Math.ceil(baseBorderRadius * (2 / 3))}px`, // for default base becomes 4
lg: `${Math.ceil(baseBorderRadius * (5 / 3))}px`, // for default base becomes 10
pill: '9999px',
circle: '100%',
};
@@ -48,9 +48,6 @@ const aubergineTheme: NewThemeOptions = {
hoverFactor: 0.07,
tonalOffset: 0.15,
},
shape: {
borderRadius: 6,
},
};
export default aubergineTheme;
@@ -70,9 +70,6 @@ const desertBloomTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.15,
},
shape: {
borderRadius: 6,
},
};
export default desertBloomTheme;
@@ -60,9 +60,6 @@ const gildedGroveTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.15,
},
shape: {
borderRadius: 5,
},
};
export default gildedGroveTheme;
@@ -75,9 +75,6 @@ const gloomTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.15,
},
shape: {
borderRadius: 5,
},
};
export default gloomTheme;
@@ -48,9 +48,6 @@ const marsTheme: NewThemeOptions = {
hoverFactor: 0.05,
tonalOffset: 0.2,
},
shape: {
borderRadius: 4,
},
};
export default marsTheme;
@@ -74,9 +74,6 @@ const sapphireDuskTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.15,
},
shape: {
borderRadius: 5,
},
};
export default sapphireDuskTheme;
@@ -48,9 +48,6 @@ const synthwaveTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.15,
},
shape: {
borderRadius: 6,
},
};
export default synthwaveTheme;
@@ -48,9 +48,6 @@ const tronTheme: NewThemeOptions = {
hoverFactor: 0.05,
tonalOffset: 0.2,
},
shape: {
borderRadius: 6,
},
};
export default tronTheme;
@@ -48,9 +48,6 @@ const victorianTheme: NewThemeOptions = {
hoverFactor: 0.07,
tonalOffset: 0.15,
},
shape: {
borderRadius: 6,
},
typography: {
fontFamily: '"Georgia", "Times New Roman", serif',
fontFamilyMonospace: "'Courier New', monospace",
@@ -48,9 +48,6 @@ const zenTheme: NewThemeOptions = {
hoverFactor: 0.03,
tonalOffset: 0.2,
},
shape: {
borderRadius: 8,
},
};
export default zenTheme;
@@ -1013,6 +1013,10 @@ export interface FeatureToggles {
*/
kubernetesAuthzApis?: boolean;
/**
* Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints
*/
kubernetesAuthZHandlerRedirect?: boolean;
/**
* Registers AuthZ resource permission /apis endpoints
*/
kubernetesAuthzResourcePermissionApis?: boolean;
@@ -5,9 +5,10 @@ import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { textToDataContainer } from '../FlameGraph/testHelpers';
import { ColorScheme } from '../types';
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
import FlameGraphTopTableContainer, { buildFilteredTable } from './FlameGraphTopTableContainer';
describe('FlameGraphTopTableContainer', () => {
const setup = () => {
@@ -52,7 +53,10 @@ describe('FlameGraphTopTableContainer', () => {
expect(cells).toHaveLength(60); // 16 rows
expect(cells[1].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
expect(cells[2].textContent).toEqual('31.7 K');
expect(cells[3].textContent).toEqual('31.7 Bil');
expect(cells[3].textContent).toEqual('5.58 Bil');
expect(cells[5].textContent).toEqual('total');
expect(cells[6].textContent).toEqual('16.5 K');
expect(cells[7].textContent).toEqual('16.5 Bil');
expect(cells[25].textContent).toEqual('net/http.(*conn).serve');
expect(cells[26].textContent).toEqual('5.63 K');
expect(cells[27].textContent).toEqual('5.63 Bil');
@@ -83,3 +87,111 @@ describe('FlameGraphTopTableContainer', () => {
expect(mocks.onSandwich).toHaveBeenCalledWith('net/http.HandlerFunc.ServeHTTP');
});
});
describe('buildFilteredTable', () => {
it('should group data by label and sum values', () => {
const container = textToDataContainer(`
[0////]
[1][2]
[3][4]
`);
const result = buildFilteredTable(container!);
expect(result).toEqual({
'0': { self: 1, total: 7, totalRight: 0 },
'1': { self: 0, total: 3, totalRight: 0 },
'2': { self: 0, total: 3, totalRight: 0 },
'3': { self: 3, total: 3, totalRight: 0 },
'4': { self: 3, total: 3, totalRight: 0 },
});
});
it('should sum values for duplicate labels', () => {
const container = textToDataContainer(`
[0///]
[1][1]
`);
const result = buildFilteredTable(container!);
expect(result).toEqual({
'0': { self: 0, total: 6, totalRight: 0 },
'1': { self: 6, total: 6, totalRight: 0 },
});
});
it('should filter by matchedLabels when provided', () => {
const container = textToDataContainer(`
[0////]
[1][2]
[3][4]
`);
const matchedLabels = new Set(['1', '3']);
const result = buildFilteredTable(container!, matchedLabels);
expect(result).toEqual({
'1': { self: 0, total: 3, totalRight: 0 },
'3': { self: 3, total: 3, totalRight: 0 },
});
});
it('should handle empty matchedLabels set', () => {
const container = textToDataContainer(`
[0////]
[1][2]
[3][4]
`);
const matchedLabels = new Set<string>();
const result = buildFilteredTable(container!, matchedLabels);
expect(result).toEqual({});
});
it('should handle data with no matches', () => {
const container = textToDataContainer(`
[0////]
[1][2]
[3][4]
`);
const matchedLabels = new Set(['9']);
const result = buildFilteredTable(container!, matchedLabels);
expect(result).toEqual({});
});
it('should work without matchedLabels filter', () => {
const container = textToDataContainer(`
[0]
[1]
`);
const result = buildFilteredTable(container!);
expect(result).toEqual({
'0': { self: 0, total: 3, totalRight: 0 },
'1': { self: 3, total: 3, totalRight: 0 },
});
});
it('should not inflate totals for recursive calls', () => {
const container = textToDataContainer(`
[0////]
[1][2]
[3][4]
[0]
`);
const result = buildFilteredTable(container!);
expect(result).toEqual({
'0': { self: 4, total: 7, totalRight: 0 },
'1': { self: 0, total: 3, totalRight: 0 },
'2': { self: 0, total: 3, totalRight: 0 },
'3': { self: 0, total: 3, totalRight: 0 },
'4': { self: 3, total: 3, totalRight: 0 },
});
});
});
@@ -53,28 +53,7 @@ const FlameGraphTopTableContainer = memo(
onTableSort,
colorScheme,
}: Props) => {
const table = useMemo(() => {
// Group the data by label, we show only one row per label and sum the values
// TODO: should be by filename + funcName + linenumber?
let filteredTable: { [key: string]: TableData } = Object.create(null);
for (let i = 0; i < data.data.length; i++) {
const value = data.getValue(i);
const valueRight = data.getValueRight(i);
const self = data.getSelf(i);
const label = data.getLabel(i);
// If user is doing text search we filter out labels in the same way we highlight them in flame graph.
if (!matchedLabels || matchedLabels.has(label)) {
filteredTable[label] = filteredTable[label] || {};
filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self;
filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value;
filteredTable[label].totalRight = filteredTable[label].totalRight
? filteredTable[label].totalRight + valueRight
: valueRight;
}
}
return filteredTable;
}, [data, matchedLabels]);
const table = useMemo(() => buildFilteredTable(data, matchedLabels), [data, matchedLabels]);
const styles = useStyles2(getStyles);
const theme = useTheme2();
@@ -124,6 +103,49 @@ const FlameGraphTopTableContainer = memo(
FlameGraphTopTableContainer.displayName = 'FlameGraphTopTableContainer';
function buildFilteredTable(data: FlameGraphDataContainer, matchedLabels?: Set<string>) {
// Group the data by label, we show only one row per label and sum the values
// TODO: should be by filename + funcName + linenumber?
let filteredTable: { [key: string]: TableData } = Object.create(null);
// Track call stack to detect recursive calls
const callStack: string[] = [];
for (let i = 0; i < data.data.length; i++) {
const value = data.getValue(i);
const valueRight = data.getValueRight(i);
const self = data.getSelf(i);
const label = data.getLabel(i);
const level = data.getLevel(i);
// Maintain call stack based on level changes
while (callStack.length > level) {
callStack.pop();
}
// Check if this is a recursive call (same label already in call stack)
const isRecursive = callStack.some((entry) => entry === label);
// If user is doing text search we filter out labels in the same way we highlight them in flame graph.
if (!matchedLabels || matchedLabels.has(label)) {
filteredTable[label] = filteredTable[label] || {};
filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self;
// Only add to total if this is not a recursive call
if (!isRecursive) {
filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value;
filteredTable[label].totalRight = filteredTable[label].totalRight
? filteredTable[label].totalRight + valueRight
: valueRight;
}
}
// Add current call to the stack
callStack.push(label);
}
return filteredTable;
}
function buildTableDataFrame(
data: FlameGraphDataContainer,
table: { [key: string]: TableData },
@@ -365,4 +387,6 @@ const getStylesActionCell = () => {
};
};
export { buildFilteredTable };
export default FlameGraphTopTableContainer;
@@ -211,23 +211,12 @@ class DataSourceWithBackend<
// Use the new query service for explore
if (config.featureToggles.queryServiceFromExplore && request.app === CoreApp.Explore) {
// make sure query-service is enabled on the backend
const isQueryServiceEnabled = config.featureToggles.queryService;
const isExperimentalAPIsEnabled = config.featureToggles.grafanaAPIServerWithExperimentalAPIs;
if (!isQueryServiceEnabled && !isExperimentalAPIsEnabled) {
console.warn('feature toggle queryServiceFromExplore also requires the queryService to be running');
} else {
url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`;
}
url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`;
}
// Use the new query service
if (config.featureToggles.queryServiceFromUI) {
if (!(config.featureToggles.queryService || config.featureToggles.grafanaAPIServerWithExperimentalAPIs)) {
console.warn('feature toggle queryServiceFromUI also requires the queryService to be running');
} else {
url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`;
}
url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`;
}
if (hasExpr) {
@@ -81,7 +81,7 @@ const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => {
wrapper: css({
display: 'inline-flex',
padding: '1px 4px',
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
background: bgColor,
border: `1px solid ${borderColor}`,
color: textColor,
@@ -183,6 +183,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
flex: 1,
}),
imagePreview: css({
borderRadius: theme.shape.radius.lg,
maxWidth: '100%',
maxHeight: '80vh',
objectFit: 'contain',
@@ -43,7 +43,7 @@ ValuePill.displayName = 'ValuePill';
const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({
wrapper: css({
display: 'inline-flex',
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
color: theme.colors.text.primary,
background: theme.colors.background.secondary,
padding: theme.spacing(0.25),
@@ -67,7 +67,7 @@ const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({
separator: css({
background: theme.colors.border.weak,
width: '2px',
width: '1px',
height: '100%',
marginRight: theme.spacing(0.5),
}),
@@ -3,7 +3,7 @@
exports[`CustomScrollbar renders correctly 1`] = `
<div>
<div
class="css-rzpihd"
class="css-1bz6b2c"
style="position: relative; overflow: hidden; width: 100%; height: auto; min-height: 0; max-height: 100%;"
>
<div
@@ -183,7 +183,7 @@ export const getCheckboxStyles = (theme: GrafanaTheme2, invalid = false) => {
display: 'inline-block',
width: theme.spacing(checkboxSize),
height: theme.spacing(checkboxSize),
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
background: theme.components.input.background,
border: `1px solid ${getBorderColor(theme.components.input.borderColor)}`,
@@ -3,15 +3,13 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getModalStyles = (theme: GrafanaTheme2) => {
const borderRadius = theme.shape.radius.default;
return {
modal: css({
position: 'fixed',
zIndex: theme.zIndex.modal,
background: theme.colors.background.primary,
boxShadow: theme.shadows.z3,
borderRadius,
borderRadius: theme.shape.radius.lg,
border: `1px solid ${theme.colors.border.weak}`,
backgroundClip: 'padding-box',
outline: 'none',
@@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
import { PureComponent } from 'react';
@@ -136,7 +136,7 @@ class UnthemedCodeEditor extends PureComponent<Props> {
const value = this.props.value ?? '';
const longText = value.length > 100;
const containerStyles = this.props.containerStyles ?? getStyles(theme).container;
const containerStyles = cx(getStyles(theme).container, this.props.containerStyles);
const options: MonacoOptions = {
wordWrap: wordWrap ? 'on' : 'off',
@@ -203,6 +203,7 @@ const getStyles = (theme: GrafanaTheme2) => {
container: css({
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.components.input.borderColor}`,
overflow: 'hidden',
}),
};
};
@@ -127,7 +127,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
alignItems: 'center',
lineHeight: 1,
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
margin: theme.spacing(0.25, 1, 0.25, 0),
padding: theme.spacing(0.25, 0, 0.25, 1),
color: theme.colors.text.primary,
@@ -85,7 +85,7 @@ const getTagStyles = (theme: GrafanaTheme2, name: string, colorIndex?: number) =
whiteSpace: 'pre',
textShadow: 'none',
padding: '3px 6px',
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
}),
hover: css({
'&:hover': {
-2
View File
@@ -4,7 +4,6 @@ import (
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/iam"
"github.com/grafana/grafana/pkg/registry/apis/ofrep"
@@ -22,7 +21,6 @@ type Service struct{}
func ProvideRegistryServiceSink(
_ *dashboardinternal.DashboardsAPIBuilder,
_ *dashboardsnapshot.SnapshotsAPIBuilder,
_ *featuretoggle.FeatureFlagAPIBuilder,
_ *datasource.DataSourceAPIBuilder,
_ *folders.FolderAPIBuilder,
_ *iam.IdentityAccessManagementAPIBuilder,
+1 -2
View File
@@ -8,13 +8,12 @@ import (
"github.com/grafana/authlib/types"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/libraryelements"
)
func newLegacyAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer {
func newLegacyAuthorizer(ac accesscontrol.AccessControl) authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
// Note that we will return Allow more than expected.
+10 -31
View File
@@ -20,6 +20,7 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
@@ -33,7 +34,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
@@ -48,7 +48,6 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/sort"
@@ -103,12 +102,10 @@ type DashboardsAPIBuilder struct {
dashStore dashboards.Store
QuotaService quota.Service
ProvisioningService provisioning.ProvisioningService
cfg *setting.Cfg
minRefreshInterval string
dualWriter dualwrite.Service
folderClientProvider client.K8sHandlerProvider
log log.Logger
reg prometheus.Registerer
isStandalone bool // skips any handling including anything to do with legacy storage
}
@@ -118,7 +115,6 @@ func RegisterAPIService(
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
pluginStore pluginstore.Store,
datasourceService datasources.DataSourceService,
dashboardPermissions dashboards.PermissionsRegistrationService,
dashboardPermissionsSvc accesscontrol.DashboardPermissionsService,
@@ -142,10 +138,8 @@ func RegisterAPIService(
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folders.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter, features)
dashLog := log.New("grafana-apiserver.dashboards")
builder := &DashboardsAPIBuilder{
log: dashLog,
authorizer: newLegacyAuthorizer(accessControl, dashLog),
authorizer: newLegacyAuthorizer(accessControl),
dashboardService: dashboardService,
dashboardPermissions: dashboardPermissions,
dashboardPermissionsSvc: dashboardPermissionsSvc,
@@ -158,7 +152,7 @@ func RegisterAPIService(
dashStore: dashStore,
QuotaService: quotaService,
ProvisioningService: provisioning,
cfg: cfg,
minRefreshInterval: cfg.MinRefreshInterval,
dualWriter: dual,
folderClientProvider: newSimpleFolderClientProvider(folderClient),
@@ -166,7 +160,6 @@ func RegisterAPIService(
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
DashboardService: dashboardService,
},
reg: reg,
}
migration.RegisterMetrics(reg)
@@ -177,24 +170,10 @@ func RegisterAPIService(
return builder
}
func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceInfoProvider, pluginStore *pluginstore.Service) *DashboardsAPIBuilder {
// TODO: Plugin store will soon be removed,
// as the cases for plugin fetching is not needed. Keeping it now to not break implementation
if pluginStore == nil {
panic("pluginStore is nil")
}
logger := log.New("grafana-apiserver.dashboards")
func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceInfoProvider) *DashboardsAPIBuilder {
migration.Initialize(datasourceProvider)
return &DashboardsAPIBuilder{
log: logger,
reg: prometheus.NewRegistry(),
cfg: &setting.Cfg{
MinRefreshInterval: "10s",
},
minRefreshInterval: "10s",
accessClient: ac,
authorizer: authsvc.NewResourceAuthorizer(ac),
features: features,
@@ -336,7 +315,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil {
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil {
return apierrors.NewBadRequest(err.Error())
}
@@ -422,7 +401,7 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil {
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil {
return apierrors.NewBadRequest(err.Error())
}
@@ -592,7 +571,7 @@ func (b *DashboardsAPIBuilder) storageForVersion(
return nil
}
legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg, b.dashboardPermissions, b.accessClient)
legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, opts.MetricsRegister, b.dashboardPermissions, b.accessClient)
if err != nil {
return err
}
@@ -692,7 +671,7 @@ func (b *DashboardsAPIBuilder) verifyFolderAccessPermissions(ctx context.Context
var accessInfo folders.FolderAccessInfo
err = runtime.DefaultUnstructuredConverter.FromUnstructured(resp.Object, &accessInfo)
if err != nil {
b.log.Error("Failed to convert folder access response", "error", err)
logging.FromContext(ctx).Error("Failed to convert folder access response", "error", err)
return dashboards.ErrFolderAccessDenied
}
@@ -10,6 +10,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"github.com/grafana/grafana-app-sdk/logging"
v0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
v1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
@@ -62,7 +63,7 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
}
if alwaysLogSchemaValidationErrors && len(errors) > 0 {
b.log.Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil)
logging.FromContext(ctx).Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil)
}
if errorOnSchemaMismatches {
+57 -20
View File
@@ -85,6 +85,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "type",
In: "query",
Description: "search dashboards or folders. When empty, this will search both",
Required: false,
Schema: spec.StringProperty().WithEnum("folder", "dashboard"),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "folder",
@@ -94,6 +103,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "facet",
In: "query",
Description: "count distinct terms for selected fields",
Required: false,
Schema: spec.ArrayProperty(spec.StringProperty()),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "tags",
In: "query",
Description: "tag query filter",
Required: false,
Schema: spec.ArrayProperty(spec.StringProperty()),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "sort",
@@ -124,6 +151,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "limit",
In: "query",
Description: "number of results to return",
Required: false,
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "explain",
In: "query",
Description: "add debugging info that may help explain why the result matched",
Required: false,
Schema: spec.BoolProperty(),
},
},
},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
@@ -257,33 +302,25 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}
searchRequest.Fields = fields
types := queryParams["type"]
var federate *resourcepb.ResourceKey
switch len(types) {
case 0:
// When no type specified, search for dashboards
// Search dashboards or folders (or both)
switch queryParams.Get("type") {
case "folder":
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), folders.RESOURCE)
case "dashboard":
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE)
// Currently a search query is across folders and dashboards
if err == nil {
federate, err = asResourceKey(user.GetNamespace(), folders.RESOURCE)
}
case 1:
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
case 2:
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
if err == nil {
federate, err = asResourceKey(user.GetNamespace(), types[1])
}
default:
err = apierrors.NewBadRequest("too many type requests")
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE)
if err == nil {
federate, _ := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if federate != nil {
searchRequest.Federated = []*resourcepb.ResourceKey{federate}
}
}
}
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if federate != nil {
searchRequest.Federated = []*resourcepb.ResourceKey{federate}
}
// Add sorting
if queryParams.Has("sort") {
+2 -5
View File
@@ -9,9 +9,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
claims "github.com/grafana/authlib/types"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -145,14 +144,12 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
access.CanAdmin, _ = r.accessControl.Evaluate(ctx, user, adminEvaluator)
deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope)
access.CanDelete, _ = r.accessControl.Evaluate(ctx, user, deleteEvaluator)
access.CanStar = user.IsIdentityType(claims.TypeUser)
access.CanStar = user.IsIdentityType(authlib.TypeUser)
access.AnnotationsPermissions = &dashboard.AnnotationPermission{}
r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard)
r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization)
// FIXME!!!! does not get the title!
// The title property next to unstructured and not found in this model
title := obj.FindTitle("")
access.Slug = slugify.Slugify(title)
access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug)
@@ -1,5 +0,0 @@
This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature.
In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance.
For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file.
-237
View File
@@ -1,237 +0,0 @@
package featuretoggle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web"
)
func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState {
state := v0alpha1.ResolvedToggleState{
TypeMeta: v1.TypeMeta{
APIVersion: v0alpha1.APIVERSION,
Kind: "ResolvedToggleState",
},
Enabled: b.features.GetEnabled(ctx),
RestartRequired: b.features.IsRestartRequired(),
}
// Reference to the object that defined the values
startupRef := &common.ObjectReference{
Namespace: "system",
Name: "startup",
}
startup := b.features.GetStartupFlags()
warnings := b.features.GetWarning()
for _, f := range b.features.GetFlags() {
name := f.Name
if b.features.IsHiddenFromAdminPage(name, false) {
continue
}
toggle := v0alpha1.ToggleStatus{
Name: name,
Description: f.Description, // simplify the UI changes
Stage: f.Stage.String(),
Enabled: state.Enabled[name],
Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef,
Warning: warnings[name],
}
if f.Expression == "true" && toggle.Enabled {
toggle.Source = nil
}
_, inStartup := startup[name]
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
state.Toggles = append(state.Toggles, toggle)
}
if toggle.Writeable {
state.AllowEditing = true
}
}
// Make sure the user can actually write values
if state.AllowEditing {
state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil)
}
return state
}
func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u identity.Requester) bool {
if u == nil {
u, _ = identity.GetRequester(ctx)
if u == nil {
return false
}
}
ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead))
return ok && err == nil
}
func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u identity.Requester) bool {
if u == nil {
u, _ = identity.GetRequester(ctx)
if u == nil {
return false
}
}
ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite))
return ok && err == nil
}
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
b.handlePatchCurrent(w, r)
return
}
// Check if the user can access toggle info
ctx := r.Context()
user, err := identity.GetRequester(ctx)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if !b.userCanRead(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotRead",
errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.GetLogin())
errhttp.Write(ctx, err, w)
return
}
// Write the state to the response body
state := b.getResolvedToggleState(r.Context())
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(state)
}
// NOTE: authz is already handled by the authorizer
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() {
err := errutil.Forbidden("featuretoggle.disabled",
errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration")
errhttp.Write(ctx, err, w)
return
}
user, err := identity.GetRequester(ctx)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if !b.userCanWrite(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotWrite",
errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.GetLogin())
errhttp.Write(ctx, err, w)
return
}
request := v0alpha1.ResolvedToggleState{}
err = web.Bind(r, &request)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if len(request.Toggles) > 0 {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section")
errhttp.Write(ctx, err, w)
return
}
changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side
for k, v := range request.Enabled {
current := b.features.IsEnabled(ctx, k)
if current != v {
if !b.features.IsEditableFromAdminPage(k) {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k)
errhttp.Write(ctx, err, w)
w.WriteHeader(http.StatusBadRequest)
return
}
changes[k] = strconv.FormatBool(v)
}
}
if len(changes) == 0 {
w.WriteHeader(http.StatusNotModified)
return
}
payload := featuremgmt.FeatureToggleWebhookPayload{
FeatureToggles: changes,
User: user.GetEmail(),
}
err = sendWebhookUpdate(b.features.Settings, payload)
if err != nil && b.cfg.Env != setting.Dev {
err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err)
errhttp.Write(ctx, err, w)
return
}
b.features.SetRestartRequired()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("feature toggles updated successfully"))
}
func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode >= http.StatusBadRequest {
if body, err := io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
} else {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
}
}
return nil
}
@@ -1,459 +0,0 @@
package featuretoggle
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetFeatureToggles(t *testing.T) {
t.Run("fails without adequate permissions", func(t *testing.T) {
features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}})
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
callGetWith(t, b, http.StatusUnauthorized)
})
t.Run("should be able to get feature toggles", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{})
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 2)
t1, _ := findResult(t, result, "toggle1")
assert.True(t, t1.Enabled)
t2, _ := findResult(t, result, "toggle2")
assert.False(t, t2.Enabled)
})
t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 1)
assert.Equal(t, "toggle2", result.Toggles[0].Name)
})
t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
AllowEditing: true,
UpdateWebhook: "bogus",
}
b := newTestAPIBuilder(t, features, disabled, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 1)
assert.Equal(t, "toggle2", result.Toggles[0].Name)
assert.False(t, result.Toggles[0].Writeable)
})
t.Run("feature toggle defailts", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageUnknown,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageExperimental,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStagePrivatePreview,
}, {
Name: "toggle4",
Stage: featuremgmt.FeatureStagePublicPreview,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle6",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
}, {
Name: "toggle7",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: false,
},
}
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{})
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 4)
_, ok := findResult(t, result, "toggle1")
assert.False(t, ok)
_, ok = findResult(t, result, "toggle2")
assert.False(t, ok)
_, ok = findResult(t, result, "toggle3")
assert.False(t, ok)
})
t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) {
settings := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "bogus",
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
t4, ok := findResult(t, result, "toggle4")
assert.True(t, ok)
assert.True(t, t4.Writeable)
t5, ok := findResult(t, result, "toggle5")
assert.True(t, ok)
assert.True(t, t5.Writeable)
t6, ok := findResult(t, result, "toggle6")
assert.True(t, ok)
assert.True(t, t6.Writeable)
})
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
settings := setting.FeatureMgmtSettings{
AllowEditing: false,
UpdateWebhook: "",
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 4)
t4, ok := findResult(t, result, "toggle4")
assert.True(t, ok)
assert.False(t, t4.Writeable)
t5, ok := findResult(t, result, "toggle5")
assert.True(t, ok)
assert.False(t, t5.Writeable)
t6, ok := findResult(t, result, "toggle6")
assert.True(t, ok)
assert.False(t, t6.Writeable)
})
})
}
func TestSetFeatureToggles(t *testing.T) {
t.Run("fails when the user doesn't have write permissions", func(t *testing.T) {
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
}
features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}})
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized)
assert.Equal(t, "missing write permission", msg)
})
t.Run("fails when update toggle url is not set", func(t *testing.T) {
s := setting.FeatureMgmtSettings{
AllowEditing: true,
}
b := newTestAPIBuilder(t, nil, []string{}, s)
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden)
assert.Equal(t, "feature toggles are read-only", msg)
})
t.Run("fails with non-existent toggle", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle3": true,
},
}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
t.Run("fails with read-only toggles", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2", "toggle3"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
ReadOnlyToggles: map[string]struct{}{
"toggle3": {},
},
}
t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagFeatureToggleAdminPage: true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
callPatchWith(t, b, update, http.StatusNotModified)
})
t.Run("because it is not GA or Deprecated", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle2": true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
t.Run("because it is configured to be read-only", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle2": true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
})
t.Run("when all conditions met", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle4",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
},
}
disabled := []string{"toggle2", "toggle3", "toggle4"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
UpdateWebhookToken: "token",
ReadOnlyToggles: map[string]struct{}{
"toggle3": {},
},
}
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle4": true,
"toggle5": false,
},
}
t.Run("fail when webhook request is not successful", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusInternalServerError)
assert.Equal(t, "an error occurred while updating feeature toggles", msg)
})
t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
b.cfg.Env = setting.Dev
callPatchWith(t, b, update, http.StatusOK)
})
t.Run("succeed when webhook request is successful", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization"))
var req featuremgmt.FeatureToggleWebhookPayload
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "true", req.FeatureToggles["toggle4"])
assert.Equal(t, "false", req.FeatureToggles["toggle5"])
w.WriteHeader(http.StatusOK)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusOK)
assert.Equal(t, "feature toggles updated successfully", msg)
})
})
}
func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) {
t.Helper()
for _, t := range result.Toggles {
if t.Name == name {
return t, true
}
}
return v0alpha1.ToggleStatus{}, false
}
func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState {
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
req := &http.Request{
Method: "GET",
Header: http.Header{},
}
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{}))
b.handleCurrentStatus(w, req)
rts := v0alpha1.ResolvedToggleState{}
require.NoError(t, json.Unmarshal(w.Body(), &rts))
require.Equal(t, expectedCode, w.Status())
// Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state
for i, t := range rts.Toggles {
if t.Name == "featureToggleAdminPage" {
rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...)
}
}
return rts
}
func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string {
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
body, err := json.Marshal(update)
require.NoError(t, err)
req := &http.Request{
Method: "PATCH",
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{},
}
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{}))
b.handleCurrentStatus(w, req)
require.NotNil(t, w.Body())
require.Equal(t, expectedCode, w.Status())
// Extract the public facing message if this is an error
if w.Status() > 399 {
res := map[string]any{}
require.NoError(t, json.Unmarshal(w.Body(), &res))
return res["message"].(string)
}
return string(w.Body())
}
func newTestAPIBuilder(
t *testing.T,
serverFeatures []*featuremgmt.FeatureFlag,
disabled []string, // the flags that are disabled
settings setting.FeatureMgmtSettings,
) *FeatureFlagAPIBuilder {
t.Helper()
features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, serverFeatures...), disabled...)
return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{})
}
@@ -1,95 +0,0 @@
package featuretoggle
import (
"context"
"fmt"
"sync"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
_ rest.Storage = (*featuresStorage)(nil)
_ rest.Scoper = (*featuresStorage)(nil)
_ rest.SingularNameProvider = (*featuresStorage)(nil)
_ rest.Lister = (*featuresStorage)(nil)
_ rest.Getter = (*featuresStorage)(nil)
)
type featuresStorage struct {
resource *utils.ResourceInfo
tableConverter rest.TableConvertor
features *v0alpha1.FeatureList
featuresOnce sync.Once
}
// NOTE! this does not depend on config or any system state!
// In the future, the existence of features (and their properties) can be defined dynamically
func NewFeaturesStorage() *featuresStorage {
resourceInfo := v0alpha1.FeatureResourceInfo
return &featuresStorage{
resource: &resourceInfo,
tableConverter: resourceInfo.TableConverter(),
}
}
func (s *featuresStorage) New() runtime.Object {
return s.resource.NewFunc()
}
func (s *featuresStorage) Destroy() {}
func (s *featuresStorage) NamespaceScoped() bool {
return false
}
func (s *featuresStorage) GetSingularName() string {
return s.resource.GetSingularName()
}
func (s *featuresStorage) NewList() runtime.Object {
return s.resource.NewListFunc()
}
func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *featuresStorage) init() {
s.featuresOnce.Do(func() {
rv := "1"
features, _ := featuremgmt.GetEmbeddedFeatureList()
for _, feature := range features.Items {
if feature.ResourceVersion > rv {
rv = feature.ResourceVersion
}
}
features.ResourceVersion = rv
s.features = &features
})
}
func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
s.init()
if s.features == nil {
return nil, fmt.Errorf("error loading embedded features")
}
return s.features, nil
}
func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
s.init()
for idx, flag := range s.features.Items {
if flag.Name == name {
return &s.features.Items[idx], nil
}
}
return nil, fmt.Errorf("not found")
}
-148
View File
@@ -1,148 +0,0 @@
package featuretoggle
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
common "k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil)
var _ builder.APIGroupRouteProvider = (*FeatureFlagAPIBuilder)(nil)
var gv = v0alpha1.SchemeGroupVersion
// This is used just so wire has something unique to return
type FeatureFlagAPIBuilder struct {
features *featuremgmt.FeatureManager
accessControl accesscontrol.AccessControl
cfg *setting.Cfg
}
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features, accessControl, cfg}
}
func RegisterAPIService(features *featuremgmt.FeatureManager,
accessControl accesscontrol.AccessControl,
apiregistration builder.APIRegistrar,
cfg *setting.Cfg,
registerer prometheus.Registerer,
) *FeatureFlagAPIBuilder {
builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg)
apiregistration.RegisterAPI(builder)
return builder
}
func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion {
return gv
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&v0alpha1.Feature{},
&v0alpha1.FeatureList{},
&v0alpha1.FeatureToggles{},
&v0alpha1.FeatureTogglesList{},
&v0alpha1.ResolvedToggleState{},
)
}
func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
addKnownTypes(scheme, gv)
// Link this version to the internal representation.
// This is used for server-side-apply (PATCH), and avoids the error:
// "no kind is registered for the type"
addKnownTypes(scheme, schema.GroupVersion{
Group: gv.Group,
Version: runtime.APIVersionInternal,
})
// If multiple versions exist, then register conversions from zz_generated.conversion.go
// if err := playlist.RegisterConversions(scheme); err != nil {
// return err
// }
metav1.AddToGroupVersion(scheme, gv)
return scheme.SetVersionPriority(gv)
}
func (b *FeatureFlagAPIBuilder) AllowedV0Alpha1Resources() []string {
return nil
}
func (b *FeatureFlagAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, _ builder.APIGroupOptions) error {
featureStore := NewFeaturesStorage()
toggleStore := NewTogglesStorage(b.features)
storage := map[string]rest.Storage{}
storage[featureStore.resource.StoragePath()] = featureStore
storage[toggleStore.resource.StoragePath()] = toggleStore
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
return nil
}
func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return v0alpha1.GetOpenAPIDefinitions
}
func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return nil // default authorizer is fine
}
// Register additional routes with the server
func (b *FeatureFlagAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema
tags := []string{"Editor"}
return &builder.APIRoutes{
Root: []builder.APIRouteHandler{
{
Path: "current",
Spec: &spec3.PathProps{
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Tags: tags,
Summary: "Current configuration with details",
Description: "Show details about the current flags and where they come from",
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
StatusCodeResponses: map[int]*spec3.Response{
200: {
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &stateSchema,
},
},
},
Description: "OK",
},
},
},
},
},
},
},
},
Handler: b.handleCurrentStatus,
},
},
}
}
@@ -1,91 +0,0 @@
package featuretoggle
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
_ rest.Storage = (*togglesStorage)(nil)
_ rest.Scoper = (*togglesStorage)(nil)
_ rest.SingularNameProvider = (*togglesStorage)(nil)
_ rest.Lister = (*togglesStorage)(nil)
_ rest.Getter = (*togglesStorage)(nil)
)
type togglesStorage struct {
resource *utils.ResourceInfo
tableConverter rest.TableConvertor
// The startup toggles
startup *v0alpha1.FeatureToggles
}
func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage {
resourceInfo := v0alpha1.TogglesResourceInfo
return &togglesStorage{
resource: &resourceInfo,
startup: &v0alpha1.FeatureToggles{
TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
Name: "startup",
Namespace: "system",
CreationTimestamp: metav1.Now(),
},
Spec: features.GetStartupFlags(),
},
tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()),
}
}
func (s *togglesStorage) New() runtime.Object {
return s.resource.NewFunc()
}
func (s *togglesStorage) Destroy() {}
func (s *togglesStorage) NamespaceScoped() bool {
return true
}
func (s *togglesStorage) GetSingularName() string {
return s.resource.GetSingularName()
}
func (s *togglesStorage) NewList() runtime.Object {
return s.resource.NewListFunc()
}
func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
flags := &v0alpha1.FeatureTogglesList{
Items: []v0alpha1.FeatureToggles{*s.startup},
}
return flags, nil
}
func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, false) // allow system
if err != nil {
return nil, err
}
if info.Value != "" && info.Value != "system" {
return nil, fmt.Errorf("only system namespace is currently supported")
}
if name != "startup" {
return nil, fmt.Errorf("only system/startup is currently supported")
}
return s.startup, nil
}
-5
View File
@@ -72,11 +72,6 @@ func newParentsGetter(getter rest.Getter, maxDepth int) parentsGetter {
break
}
if len(info.Items) >= maxDepth {
err = folderLegacy.ErrMaximumDepthReached
break
}
found[parentFolder.Name] = true
folder = parentFolder
}
-15
View File
@@ -111,21 +111,6 @@ func TestParents(t *testing.T) {
},
expectedErr: "cyclic folder references found",
},
{
name: "too deep",
request: input{
name: "test",
folder: "p1",
},
maxDepth: 3,
expectedErr: "[folder.maximum-depth-reached]",
expected: &folders.FolderInfoList{Items: []folders.FolderInfo{
{Name: "test", Parent: "p1"},
{Name: "p1", Parent: "p2"},
{Name: "p2", Parent: "p3"},
{Name: "p3", Parent: "p4"}, // should not try calling p4
}},
},
}
for _, tt := range tests {
+61 -30
View File
@@ -2,6 +2,7 @@ package folders
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
@@ -57,35 +58,6 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
name: "valid-name",
},
},
{
name: "should not allow creating a folder in a tree that is too deep",
input: input{
obj: &folders.Folder{
Spec: folders.FolderSpec{
Title: "foo",
},
},
annotations: map[string]string{"grafana.app/folder": "p1"}, // already max depth
name: "valid-name",
},
setupFn: func(m *grafanarest.MockStorage) {
m.On("Get", mock.Anything, "p1", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p1",
Annotations: map[string]string{"grafana.app/folder": "p2"},
},
}, nil).Maybe()
m.On("Get", mock.Anything, "p2", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p2",
Annotations: map[string]string{"grafana.app/folder": "p3"},
},
}, nil).Maybe()
},
err: folder.ErrMaximumDepthReached,
},
{
name: "should return error when title is empty",
input: input{
@@ -111,6 +83,59 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
},
err: folder.ErrFolderCannotBeParentOfItself,
},
{
name: "should not allow creating a folder that will become too deep",
input: input{
annotations: map[string]string{utils.AnnoKeyFolder: "p1"},
obj: &folders.Folder{
Spec: folders.FolderSpec{
Title: "title",
},
ObjectMeta: metav1.ObjectMeta{
Name: "p0",
Annotations: map[string]string{"grafana.app/folder": "p1"},
},
},
name: "p0",
},
setupFn: func(m *grafanarest.MockStorage) {
m.On("Get", mock.Anything, "p1", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p1",
Annotations: map[string]string{"grafana.app/folder": "p2"},
},
}, nil)
m.On("Get", mock.Anything, "p2", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p2",
Annotations: map[string]string{"grafana.app/folder": "p3"},
},
}, nil)
m.On("Get", mock.Anything, "p3", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p3",
Annotations: map[string]string{"grafana.app/folder": "p4"},
},
}, nil)
m.On("Get", mock.Anything, "p4", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p4",
Annotations: map[string]string{"grafana.app/folder": "p5"},
},
}, nil)
m.On("Get", mock.Anything, "p5", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p5",
},
}, nil)
},
err: fmt.Errorf("folder max depth exceeded, max depth is 4"),
},
}
for _, tt := range tests {
@@ -148,7 +173,7 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
if tt.err == nil {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, tt.err)
require.Contains(t, err.Error(), tt.err.Error())
return
}
})
@@ -336,6 +361,12 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
Annotations: map[string]string{"grafana.app/folder": "p4"},
},
}, nil)
m.On("Get", mock.Anything, "p4", mock.Anything).Return(
&folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "p4",
},
}, nil)
},
wantErr: true,
},
@@ -0,0 +1,2 @@
DELETE FROM {{ .Ident .TeamTable }}
WHERE uid = {{ .Arg .Command.UID }}
+1
View File
@@ -32,6 +32,7 @@ type LegacyIdentityStore interface {
GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error)
CreateTeam(ctx context.Context, ns claims.NamespaceInfo, cmd CreateTeamCommand) (*CreateTeamResult, error)
ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error)
DeleteTeam(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamCommand) error
ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error)
ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error)
}
+14
View File
@@ -78,6 +78,12 @@ func TestIdentityQueries(t *testing.T) {
return &v
}
deleteTeam := func(q *DeleteTeamCommand) sqltemplate.SQLTemplate {
v := newDeleteTeam(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
listUserTeams := func(q *ListUserTeamsQuery) sqltemplate.SQLTemplate {
v := newListUserTeams(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
@@ -387,6 +393,14 @@ func TestIdentityQueries(t *testing.T) {
}),
},
},
sqlDeleteTeamTemplate: {
{
Name: "delete_team_basic",
Data: deleteTeam(&DeleteTeamCommand{
UID: "team-1",
}),
},
},
sqlCreateOrgUserTemplate: {
{
Name: "create_org_user_basic",
+63
View File
@@ -265,6 +265,69 @@ func (s *legacySQLStore) CreateTeam(ctx context.Context, ns claims.NamespaceInfo
return &CreateTeamResult{Team: createdTeam}, nil
}
type DeleteTeamCommand struct {
UID string
}
var sqlDeleteTeamTemplate = mustTemplate("delete_team.sql")
func newDeleteTeam(sql *legacysql.LegacyDatabaseHelper, cmd *DeleteTeamCommand) deleteTeamQuery {
return deleteTeamQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
TeamTable: sql.Table("team"),
Command: cmd,
}
}
type deleteTeamQuery struct {
sqltemplate.SQLTemplate
TeamTable string
Command *DeleteTeamCommand
}
func (r deleteTeamQuery) Validate() error {
return nil
}
func (s *legacySQLStore) DeleteTeam(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamCommand) error {
sql, err := s.sql(ctx)
if err != nil {
return err
}
req := newDeleteTeam(sql, &cmd)
if err := req.Validate(); err != nil {
return err
}
return sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
_, err := s.GetTeamInternalID(ctx, ns, GetTeamInternalIDQuery{
OrgID: ns.OrgID,
UID: cmd.UID,
})
if err != nil {
return err
}
teamDeleteReq := newDeleteTeam(sql, &cmd)
if err := teamDeleteReq.Validate(); err != nil {
return err
}
teamDeleteQuery, err := sqltemplate.Execute(sqlDeleteTeamTemplate, teamDeleteReq)
if err != nil {
return fmt.Errorf("error executing team delete template: %w", err)
}
_, err = st.Exec(ctx, teamDeleteQuery, teamDeleteReq.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to delete team: %w", err)
}
return nil
})
}
type ListTeamBindingsQuery struct {
// UID is team uid to list bindings for. If not set store should list bindings for all teams
UID string
@@ -0,0 +1,2 @@
DELETE FROM `grafana`.`team`
WHERE uid = 'team-1'
@@ -0,0 +1,2 @@
DELETE FROM "grafana"."team"
WHERE uid = 'team-1'
@@ -0,0 +1,2 @@
DELETE FROM "grafana"."team"
WHERE uid = 'team-1'
+34 -1
View File
@@ -74,7 +74,40 @@ func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation res
// Delete implements rest.GracefulDeleter.
func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
if !s.enableAuthnMutation {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
}
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, false, err
}
toBeDeleted, err := s.Get(ctx, name, nil)
if err != nil {
return nil, false, err
}
if deleteValidation != nil {
if err := deleteValidation(ctx, toBeDeleted); err != nil {
return nil, false, err
}
}
err = s.store.DeleteTeam(ctx, ns, legacy.DeleteTeamCommand{
UID: name,
})
if err != nil {
return nil, false, err
}
return &iamv0alpha1.Team{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns.Value,
},
}, true, nil
}
// Update implements rest.Updater.
+23 -64
View File
@@ -2,6 +2,7 @@ package provisioning
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
@@ -11,7 +12,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@@ -19,7 +19,6 @@ import (
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
clientrest "k8s.io/client-go/rest"
@@ -75,6 +74,11 @@ var (
_ builder.OpenAPIPostProcessor = (*APIBuilder)(nil)
)
var (
ErrRepositoryParentFolderConflict = errors.New("parent folder conflict")
ErrRepositoryDuplicatePath = errors.New("duplicate repository path")
)
// JobHistoryConfig holds configuration for job history backends
type JobHistoryConfig struct {
Loki *loki.Config `json:"loki,omitempty"`
@@ -348,7 +352,11 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
} else {
return authorizer.DecisionDeny, "editor role is required", nil
}
case "status":
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "users cannot update the status of a repository", nil
default:
if id.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
@@ -467,7 +475,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, b.repoFactory, b)
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b)
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access)
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
@@ -611,7 +619,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
}
// Exit early if we have already found errors
targetError := b.verifyAgainstExistingRepositories(cfg)
targetError := b.VerifyAgainstExistingRepositories(ctx, cfg)
if targetError != nil {
return invalidRepositoryError(a.GetName(), field.ErrorList{targetError})
}
@@ -625,64 +633,8 @@ func invalidRepositoryError(name string, list field.ErrorList) error {
name, list)
}
func (b *APIBuilder) getRepositoriesInNamespace(ctx context.Context) ([]provisioning.Repository, error) {
obj, err := b.store.List(ctx, &internalversion.ListOptions{
Limit: 100,
})
if err != nil {
return nil, err
}
all, ok := obj.(*provisioning.RepositoryList)
if !ok {
return nil, fmt.Errorf("expected repository list")
}
return all.Items, nil
}
// TODO: move this to a more appropriate place. Probably controller/validation.go
func (b *APIBuilder) verifyAgainstExistingRepositories(cfg *provisioning.Repository) *field.Error {
ctx, _, err := identity.WithProvisioningIdentity(context.Background(), cfg.Namespace)
if err != nil {
return &field.Error{Type: field.ErrorTypeInternal, Detail: err.Error()}
}
all, err := b.getRepositoriesInNamespace(request.WithNamespace(ctx, cfg.Namespace))
if err != nil {
return field.Forbidden(field.NewPath("spec"),
"Unable to verify root target: "+err.Error())
}
if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance {
// Instance sync can only be created if NO other repositories exist
for _, v := range all {
if v.Name != cfg.Name {
return field.Forbidden(field.NewPath("spec", "sync", "target"),
"Instance repository can only be created when no other repositories exist. Found: "+v.Name)
}
}
} else {
// Folder sync cannot be created if an instance repository exists
for _, v := range all {
if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance && v.Name != cfg.Name {
return field.Forbidden(field.NewPath("spec", "sync", "target"),
"Cannot create folder repository when instance repository exists: "+v.Name)
}
}
}
// Count repositories excluding the current one being created/updated
count := 0
for _, v := range all {
if v.Name != cfg.Name {
count++
}
}
if count >= 10 {
return field.Forbidden(field.NewPath("spec"),
"Maximum number of 10 repositories reached")
}
return nil
func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error {
return VerifyAgainstExistingRepositories(ctx, b.store, cfg)
}
func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
@@ -734,7 +686,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
}
// Create the repository resources factory
usageMetricCollector := usage.MetricCollector(b.tracer, b.getRepositoriesInNamespace, b.unified)
repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) {
return GetRepositoriesInNamespace(ctx, b.store)
}
usageMetricCollector := usage.MetricCollector(b.tracer, repositoryListerWrapper, b.unified)
b.usageStats.RegisterMetricsFunc(usageMetricCollector)
metrics := jobs.RegisterJobMetrics(b.registry)
@@ -1321,6 +1276,10 @@ func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository
return b.asRepository(ctx, obj, nil)
}
func (b *APIBuilder) GetRepoFactory() repository.Factory {
return b.repoFactory
}
func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) {
repo, err := b.GetRepository(ctx, name)
if err != nil {
+1 -1
View File
@@ -150,7 +150,7 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
}
// TODO: check if lister could list too many repositories or resources
all, err := b.getRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()))
all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.store)
if err != nil {
errhttp.Write(r.Context(), err, w)
return
+14 -9
View File
@@ -28,21 +28,26 @@ type HealthCheckerProvider interface {
GetHealthChecker() *controller.HealthChecker
}
type ConnectorDependencies interface {
RepoGetter
HealthCheckerProvider
repository.RepositoryValidator
GetRepoFactory() repository.Factory
}
type testConnector struct {
getter RepoGetter
factory repository.Factory
healthProvider HealthCheckerProvider
validator repository.RepositoryValidator
}
func NewTestConnector(
getter RepoGetter,
factory repository.Factory,
healthProvider HealthCheckerProvider,
) *testConnector {
func NewTestConnector(deps ConnectorDependencies) *testConnector {
return &testConnector{
factory: factory,
getter: getter,
healthProvider: healthProvider,
factory: deps.GetRepoFactory(),
getter: deps,
healthProvider: deps,
validator: deps,
}
}
@@ -181,7 +186,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.O
}
} else {
// Testing temporary repository - just run test without status update
rsp, err = repository.TestRepository(ctx, repo)
rsp, err = repository.TestRepositoryWithValidator(ctx, repo, s.validator)
if err != nil {
responder.Error(err)
return
@@ -0,0 +1,124 @@
package provisioning
import (
"context"
"fmt"
"path/filepath"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/endpoints/request"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
// RepositoryLister interface for listing repositories
type RepositoryLister interface {
List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error)
}
// GetRepositoriesInNamespace retrieves all repositories in a given namespace
func GetRepositoriesInNamespace(ctx context.Context, store RepositoryLister) ([]provisioning.Repository, error) {
var allRepositories []provisioning.Repository
continueToken := ""
for {
obj, err := store.List(ctx, &internalversion.ListOptions{
Limit: 100,
Continue: continueToken,
})
if err != nil {
return nil, err
}
repositoryList, ok := obj.(*provisioning.RepositoryList)
if !ok {
return nil, fmt.Errorf("expected repository list")
}
allRepositories = append(allRepositories, repositoryList.Items...)
continueToken = repositoryList.GetContinue()
if continueToken == "" {
break
}
}
return allRepositories, nil
}
// VerifyAgainstExistingRepositories validates a repository configuration against existing repositories
func VerifyAgainstExistingRepositories(ctx context.Context, store RepositoryLister, cfg *provisioning.Repository) *field.Error {
ctx, _, err := identity.WithProvisioningIdentity(ctx, cfg.Namespace)
if err != nil {
return &field.Error{Type: field.ErrorTypeInternal, Detail: err.Error()}
}
all, err := GetRepositoriesInNamespace(request.WithNamespace(ctx, cfg.Namespace), store)
if err != nil {
return field.Forbidden(field.NewPath("spec"),
"Unable to verify root target: "+err.Error())
}
if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance {
// Instance sync can only be created if NO other repositories exist
for _, v := range all {
if v.Name != cfg.Name {
return field.Forbidden(field.NewPath("spec", "sync", "target"),
"Instance repository can only be created when no other repositories exist. Found: "+v.Name)
}
}
} else {
// Folder sync cannot be created if an instance repository exists
for _, v := range all {
if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance && v.Name != cfg.Name {
return field.Forbidden(field.NewPath("spec", "sync", "target"),
"Cannot create folder repository when instance repository exists: "+v.Name)
}
}
}
// If repo is git, ensure no other repository is defined with a child path
if cfg.Spec.Type.IsGit() {
for _, v := range all {
// skip itself
if cfg.Name == v.Name {
continue
}
if v.URL() == cfg.URL() {
if v.Path() == cfg.Path() {
return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"),
cfg.Path(),
fmt.Sprintf("%s: %s", ErrRepositoryDuplicatePath.Error(), v.Name))
}
relPath, err := filepath.Rel(v.Path(), cfg.Path())
if err != nil {
return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"), cfg.Path(), "failed to evaluate path: "+err.Error())
}
// https://pkg.go.dev/path/filepath#Rel
// Rel will return "../" if the relative paths are not related
if !strings.HasPrefix(relPath, "../") {
return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"), cfg.Path(),
fmt.Sprintf("%s: %s", ErrRepositoryParentFolderConflict.Error(), v.Name))
}
}
}
}
// Count repositories excluding the current one being created/updated
count := 0
for _, v := range all {
if v.Name != cfg.Name {
count++
}
}
if count >= 10 {
return field.Forbidden(field.NewPath("spec"),
"Maximum number of 10 repositories reached")
}
return nil
}
-2
View File
@@ -6,7 +6,6 @@ import (
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/iam"
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
@@ -54,7 +53,6 @@ var WireSet = wire.NewSet(
// Each must be added here *and* in the ServiceSink above
dashboardinternal.RegisterAPIService,
dashboardsnapshot.RegisterAPIService,
featuretoggle.RegisterAPIService,
datasource.RegisterAPIService,
folders.RegisterAPIService,
iam.RegisterAPIService,
+4 -7
View File
@@ -53,7 +53,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
"github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/registry/apis/iam"
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
@@ -809,9 +808,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer)
if err != nil {
return nil, err
@@ -863,7 +861,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer)
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer)
teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService)
if err != nil {
return nil, err
@@ -1417,9 +1415,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer)
if err != nil {
return nil, err
@@ -1471,7 +1468,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer)
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer)
teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService)
if err != nil {
return nil, err
+8
View File
@@ -1750,6 +1750,14 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "kubernetesAuthZHandlerRedirect",
Description: "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints",
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "kubernetesAuthzResourcePermissionApis",
Description: "Registers AuthZ resource permission /apis endpoints",
+1
View File
@@ -227,6 +227,7 @@ alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,fal
alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false
alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true
kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false
kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false
kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false
kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false
restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
227 alertRuleUseFiredAtForStartsAt experimental @grafana/alerting-squad false false false
228 alertingBulkActionsInUI GA @grafana/alerting-squad false false true
229 kubernetesAuthzApis experimental @grafana/identity-access-team false false false
230 kubernetesAuthZHandlerRedirect experimental @grafana/identity-access-team false false false
231 kubernetesAuthzResourcePermissionApis experimental @grafana/identity-access-team false false false
232 kubernetesAuthnMutation experimental @grafana/identity-access-team false false false
233 restoreDashboards experimental @grafana/grafana-frontend-platform false false false
+4
View File
@@ -919,6 +919,10 @@ const (
// Registers AuthZ /apis endpoint
FlagKubernetesAuthzApis = "kubernetesAuthzApis"
// FlagKubernetesAuthZHandlerRedirect
// Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints
FlagKubernetesAuthZHandlerRedirect = "kubernetesAuthZHandlerRedirect"
// FlagKubernetesAuthzResourcePermissionApis
// Registers AuthZ resource permission /apis endpoints
FlagKubernetesAuthzResourcePermissionApis = "kubernetesAuthzResourcePermissionApis"
+43
View File
@@ -1986,6 +1986,20 @@
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesAuthZHandlerRedirect",
"resourceVersion": "1758820248165",
"creationTimestamp": "2025-09-25T17:10:48Z"
},
"spec": {
"description": "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints",
"stage": "experimental",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "kubernetesAuthnMutation",
@@ -2017,6 +2031,21 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "kubernetesAuthzEndpoints",
"resourceVersion": "1758779666607",
"creationTimestamp": "2025-09-25T05:54:26Z",
"deletionTimestamp": "2025-09-25T17:10:48Z"
},
"spec": {
"description": "Enables K8s AuthZ endpoints",
"stage": "experimental",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "kubernetesAuthzResourcePermissionApis",
@@ -3716,6 +3745,20 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "unifiedStorageUseFullNgram",
"resourceVersion": "1758820248165",
"creationTimestamp": "2025-09-25T17:10:48Z"
},
"spec": {
"description": "Use full n-gram indexing instead of edge n-gram for unified storage search",
"stage": "experimental",
"codeowner": "@grafana/search-and-storage",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "useKubernetesShortURLsAPI",
+4 -4
View File
@@ -76,7 +76,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Text: "General",
SubTitle: "Manage default preferences and settings across Grafana",
Id: navtree.NavIDCfgGeneral,
Url: "/admin/general",
Url: s.cfg.AppSubURL + "/admin/general",
Icon: "shield",
Children: generalNodeLinks,
}
@@ -121,7 +121,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Text: "Plugins and data",
SubTitle: "Install plugins and define the relationships between data",
Id: navtree.NavIDCfgPlugins,
Url: "/admin/plugins",
Url: s.cfg.AppSubURL + "/admin/plugins",
Icon: "shield",
Children: pluginsNodeLinks,
}
@@ -174,7 +174,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Text: "Users and access",
SubTitle: "Configure access for individual users, teams, and service accounts",
Id: navtree.NavIDCfgAccess,
Url: "/admin/access",
Url: s.cfg.AppSubURL + "/admin/access",
Icon: "shield",
Children: accessNodeLinks,
}
@@ -201,7 +201,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Icon: "cog",
SortWeight: navtree.WeightConfig,
Children: configNodes,
Url: "/admin",
Url: s.cfg.AppSubURL + "/admin",
}
return configNode, nil
+1 -1
View File
@@ -267,7 +267,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmode
supportBundleNode := &navtree.NavLink{
Text: "Support bundles",
Id: "support-bundles",
Url: "/support-bundles",
Url: s.cfg.AppSubURL + "/support-bundles",
Icon: "wrench",
SortWeight: navtree.WeightHelp,
}
+2 -3
View File
@@ -222,7 +222,6 @@ func (ng *AlertNG) init() error {
autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, skipInvalid bool) error {
return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, skipInvalid)
}
store := notifier.NewFileStore(cfg.OrgID, ng.KVStore)
// This function will be used by the MOA to create new Alertmanagers.
var override func(notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory
@@ -230,12 +229,12 @@ func (ng *AlertNG) init() error {
if remotePrimary {
ng.Log.Debug("Starting Grafana with remote primary mode enabled")
m.Info.WithLabelValues(metrics.ModeRemotePrimary).Set(1)
override = remote.NewRemotePrimaryFactory(cfg, store, crypto, autogenFn, m, ng.tracer)
override = remote.NewRemotePrimaryFactory(cfg, ng.KVStore, crypto, autogenFn, m, ng.tracer)
} else {
ng.Log.Debug("Starting Grafana with remote secondary mode enabled")
m.Info.WithLabelValues(metrics.ModeRemoteSecondary).Set(1)
override = remote.NewRemoteSecondaryFactory(cfg,
store,
ng.KVStore,
ng.store,
ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
crypto,
@@ -60,7 +60,7 @@ func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) {
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
override := remote.NewRemoteSecondaryFactory(remoteAMCfg,
notifier.NewFileStore(remoteAMCfg.OrgID, kvStore),
kvStore,
configStore,
10*time.Second,
notifier.NewCrypto(secretsService, configStore, log.NewNopLogger()),
@@ -7,6 +7,7 @@ import (
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -25,7 +26,7 @@ type RemotePrimaryForkedAlertmanager struct {
// NewRemotePrimaryFactory returns a function to override the default AM factory in the multi-org Alertmanager.
func NewRemotePrimaryFactory(
cfg AlertmanagerConfig,
store stateStore,
store kvstore.KVStore,
crypto Crypto,
autogenFn AutogenFn,
m *metrics.RemoteAlertmanager,
@@ -43,7 +44,7 @@ func NewRemotePrimaryFactory(
cfg.OrgID = orgID
cfg.PromoteConfig = true
l := log.New("ngalert.forked-alertmanager.remote-primary")
remoteAM, err := NewAlertmanager(ctx, cfg, store, crypto, autogenFn, m, t)
remoteAM, err := NewAlertmanager(ctx, cfg, notifier.NewFileStore(cfg.OrgID, store), crypto, autogenFn, m, t)
if err != nil {
l.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err)
return internalAM, nil
@@ -8,6 +8,7 @@ import (
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -65,7 +66,7 @@ func (c *RemoteSecondaryConfig) Validate() error {
// NewRemoteSecondaryFactory returns a function to override the default AM factory in the multi-org Alertmanager.
func NewRemoteSecondaryFactory(
cfg AlertmanagerConfig,
stateStore stateStore,
store kvstore.KVStore,
cfgStore configStore,
syncInterval time.Duration,
crypto Crypto,
@@ -79,7 +80,7 @@ func NewRemoteSecondaryFactory(
// Create the remote Alertmanager first so we don't need to unregister internal AM metrics if this fails.
cfg.OrgID = orgID
l := log.New("ngalert.forked-alertmanager.remote-secondary")
remoteAM, err := NewAlertmanager(ctx, cfg, stateStore, crypto, autogenFn, m, t)
remoteAM, err := NewAlertmanager(ctx, cfg, notifier.NewFileStore(cfg.OrgID, store), crypto, autogenFn, m, t)
if err != nil && withRemoteState {
// We can't start the internal Alertmanager without the remote state.
return nil, fmt.Errorf("failed to create remote Alertmanager, can't start the internal Alertmanager without the remote state: %w", err)
+39
View File
@@ -943,6 +943,11 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour
}
}
// Fast path for getting single value in a list
if rsp := s.tryFastPathList(ctx, req); rsp != nil {
return rsp, nil
}
if req.Limit < 1 {
req.Limit = 500 // default max 500 items in a page
}
@@ -1036,6 +1041,40 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour
return rsp, err
}
// Some list queries can be calculated with simple reads
func (s *server) tryFastPathList(ctx context.Context, req *resourcepb.ListRequest) *resourcepb.ListResponse {
if req.Source != resourcepb.ListRequest_STORE || req.Options.Key.Namespace == "" {
return nil
}
for _, v := range req.Options.Fields {
if v.Key == "metadata.name" && v.Operator == `=` {
if len(v.Values) == 1 {
read := &resourcepb.ReadRequest{
Key: req.Options.Key,
ResourceVersion: req.ResourceVersion,
}
read.Key.Name = v.Values[0]
found, err := s.Read(ctx, read)
if err != nil {
return &resourcepb.ListResponse{Error: AsErrorResult(err)}
}
// Return a value when it exists
rsp := &resourcepb.ListResponse{}
if len(found.Value) > 0 {
rsp.Items = []*resourcepb.ResourceWrapper{{
Value: found.Value,
ResourceVersion: found.ResourceVersion,
}}
}
return rsp
}
}
}
return nil
}
// isTrashItemAuthorized checks if the user has access to the trash item.
func (s *server) isTrashItemAuthorized(ctx context.Context, iter ListIterator, trashChecker claims.ItemChecker) bool {
user, ok := claims.AuthInfoFrom(ctx)
@@ -174,6 +174,39 @@ func TestSimpleServer(t *testing.T) {
require.Len(t, all.Items, 1)
require.Equal(t, updated.ResourceVersion, all.Items[0].ResourceVersion)
// Try again with a direct query
all, err = server.List(ctx, &resourcepb.ListRequest{Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Namespace: key.Namespace,
Group: key.Group,
Resource: key.Resource,
},
Fields: []*resourcepb.Requirement{{
Key: "metadata.name",
Operator: "=",
Values: []string{"not-matching"},
}},
}})
require.NoError(t, err)
require.Len(t, all.Items, 0)
// This time matching
all, err = server.List(ctx, &resourcepb.ListRequest{Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Namespace: key.Namespace,
Group: key.Group,
Resource: key.Resource,
},
Fields: []*resourcepb.Requirement{{
Key: "metadata.name",
Operator: "=",
Values: []string{"fdgsv37qslr0ga"},
}},
}})
require.NoError(t, err)
require.Len(t, all.Items, 1)
require.Equal(t, raw, all.Items[0].Value)
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{Key: key, ResourceVersion: updated.ResourceVersion})
require.NoError(t, err)
require.True(t, deleted.ResourceVersion > updated.ResourceVersion)
@@ -647,12 +647,9 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
})
t.Run("Dashboard refresh interval validations", func(t *testing.T) {
// Store original settings to restore after test
origCfg := ctx.Helper.GetEnv().Cfg
origMinRefreshInterval := origCfg.MinRefreshInterval
// Set a fixed min_refresh_interval for all tests to make them predictable
ctx.Helper.GetEnv().Cfg.MinRefreshInterval = "10s"
// Test infrastructure is configured with
// [dashboards]
// min_refresh_interval = 10s
testCases := []struct {
name string
@@ -720,9 +717,6 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
}
})
}
// Restore original settings
ctx.Helper.GetEnv().Cfg.MinRefreshInterval = origMinRefreshInterval
})
t.Run("Dashboard size limit validations", func(t *testing.T) {
+22 -2
View File
@@ -48,7 +48,7 @@ func TestIntegrationTeams(t *testing.T) {
}
func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create team and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
t.Run("should create/get/delete team using the new APIs as a GrafanaAdmin", func(t *testing.T) {
ctx := context.Background()
teamClient := helper.GetResourceClient(apis.ResourceClientArgs{
@@ -80,6 +80,16 @@ func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
require.Equal(t, createdUID, fetched.GetName())
require.Equal(t, "default", fetched.GetNamespace())
err = teamClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
require.NoError(t, err)
_, err = teamClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, "Failure", statusErr.ErrStatus.Status)
require.Contains(t, statusErr.ErrStatus.Message, "team not found")
})
t.Run("should not be able to create team when using a user with insufficient permissions", func(t *testing.T) {
@@ -193,7 +203,7 @@ func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
}
func doTeamCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create team using legacy APIs and get it using the new APIs", func(t *testing.T) {
t.Run("should create team using legacy APIs and get/delete it using the new APIs", func(t *testing.T) {
ctx := context.Background()
teamClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
@@ -232,5 +242,15 @@ func doTeamCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper)
require.Equal(t, rsp.Result.UID, team.GetName())
require.Equal(t, "default", team.GetNamespace())
err = teamClient.Resource.Delete(ctx, rsp.Result.UID, metav1.DeleteOptions{})
require.NoError(t, err)
_, err = teamClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, "Failure", statusErr.ErrStatus.Status)
require.Contains(t, statusErr.ErrStatus.Message, "team not found")
})
}
@@ -1770,6 +1770,18 @@
"type": "string"
}
},
{
"name": "type",
"in": "query",
"description": "search dashboards or folders. When empty, this will search both",
"schema": {
"type": "string",
"enum": [
"folder",
"dashboard"
]
}
},
{
"name": "folder",
"in": "query",
@@ -1778,6 +1790,28 @@
"type": "string"
}
},
{
"name": "facet",
"in": "query",
"description": "count distinct terms for selected fields",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "tags",
"in": "query",
"description": "tag query filter",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "sort",
"in": "query",
@@ -1798,6 +1832,23 @@
"value": "title"
}
}
},
{
"name": "limit",
"in": "query",
"description": "number of results to return",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "explain",
"in": "query",
"description": "add debugging info that may help explain why the result matched",
"schema": {
"type": "boolean"
}
}
],
"responses": {
+3 -1
View File
@@ -22,7 +22,9 @@ func TestIntegrationHealth(t *testing.T) {
ctx := context.Background()
repo := "test-repo-health"
helper.CreateRepo(t, TestRepo{
Name: repo,
Name: repo,
Target: "folder",
ExpectedFolders: 1,
})
// Verify the health status before calling the endpoint
+68 -1
View File
@@ -3,12 +3,14 @@ package provisioning
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -213,6 +215,70 @@ func TestIntegrationProvisioning_RepositoryValidation(t *testing.T) {
}
})
}
// Test Git repository path validation - ensure child paths are rejected
t.Run("Git repository path validation", func(t *testing.T) {
baseURL := "https://github.com/grafana/test-repo-path-validation"
pathTests := []struct {
name string
path string
expectError error
}{
{
name: "first repo with path 'demo/nested' should succeed",
path: "demo/nested",
expectError: nil,
},
{
name: "second repo with child path 'demo/nested/again' should fail",
path: "demo/nested/again",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "third repo with parent path 'demo' should fail",
path: "demo",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "fourth repo with nested child path 'demo/nested/nested-second' should fail",
path: "demo/nested/again/two",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "fifth repo with duplicate path 'demo/nested' should fail",
path: "demo/nested",
expectError: provisioningAPIServer.ErrRepositoryDuplicatePath,
},
}
for i, test := range pathTests {
t.Run(test.name, func(t *testing.T) {
repoName := fmt.Sprintf("git-path-test-%d", i+1)
gitRepo := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
"Name": repoName,
"URL": baseURL,
"Path": test.path,
"SyncEnabled": false, // Disable sync to avoid external dependencies
"SyncTarget": "folder",
})
_, err := helper.Repositories.Resource.Create(ctx, gitRepo, metav1.CreateOptions{FieldValidation: "Strict"})
if test.expectError != nil {
require.Error(t, err, "Expected error for repository with path: %s", test.path)
require.ErrorContains(t, err, test.expectError.Error(), "Error should contain expected message for path: %s", test.path)
var statusError *apierrors.StatusError
if errors.As(err, &statusError) {
require.Equal(t, metav1.StatusReasonInvalid, statusError.ErrStatus.Reason, "Should be a validation error")
require.Equal(t, http.StatusUnprocessableEntity, int(statusError.ErrStatus.Code), "Should return 422 status code")
}
} else {
require.NoError(t, err, "Expected success for repository with path: %s", test.path)
}
})
}
})
}
func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) {
@@ -364,7 +430,8 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
"Name": test.name,
"URL": test.input,
"SyncTarget": "folder",
"SyncEnabled": false, // Disable sync since we're just testing URL cleanup
"SyncEnabled": false, // Disable sync since we're just testing URL cleanup,
"Path": fmt.Sprintf("grafana-%s/", test.name),
})
_, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{})
+5
View File
@@ -545,6 +545,11 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
require.NoError(t, err)
}
dashboardsSection, err := getOrCreateSection("dashboards")
require.NoError(t, err)
_, err = dashboardsSection.NewKey("min_refresh_interval", "10s")
require.NoError(t, err)
if opts.APIServerRuntimeConfig != "" {
section, err := getOrCreateSection("grafana-apiserver")
require.NoError(t, err)
@@ -11,8 +11,13 @@ const injectedRtkApi = api
url: `/search`,
params: {
query: queryArg.query,
type: queryArg['type'],
folder: queryArg.folder,
facet: queryArg.facet,
tags: queryArg.tags,
sort: queryArg.sort,
limit: queryArg.limit,
explain: queryArg.explain,
},
}),
providesTags: ['Search'],
@@ -46,8 +51,18 @@ export type GetSearchApiResponse = /** status 200 undefined */ {
export type GetSearchApiArg = {
/** user query string */
query?: string;
/** search dashboards or folders. When empty, this will search both */
type?: 'folder' | 'dashboard';
/** search/list within a folder (not recursive) */
folder?: string;
/** count distinct terms for selected fields */
facet?: string[];
/** tag query filter */
tags?: string[];
/** sortable field */
sort?: string;
/** number of results to return */
limit?: number;
/** add debugging info that may help explain why the result matched */
explain?: boolean;
};
@@ -1,25 +1,10 @@
import { generatedAPI, GetSearchApiArg } from './endpoints.gen';
type OverrideGetSearchRequestOptions = GetSearchApiArg & {
type: string;
};
import { generatedAPI } from './endpoints.gen';
export const dashboardAPIv0alpha1 = generatedAPI.enhanceEndpoints({
addTagTypes: ['Folder', 'Dashboard'],
endpoints: {
getSearch: (endpointDefinition) => {
const originalQuery = endpointDefinition.query;
endpointDefinition.providesTags = ['Search', 'Folder', 'Dashboard'];
if (originalQuery) {
// TODO: Remove once API spec is updated with `type`
endpointDefinition.query = (requestOptions: OverrideGetSearchRequestOptions) => ({
...originalQuery(requestOptions),
params: {
...requestOptions,
type: requestOptions.type,
},
});
}
},
},
});
+1 -123
View File
@@ -23,10 +23,8 @@ import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { initializeI18n, loadNamespacedResources } from '@grafana/i18n/internal';
import {
locationService,
registerEchoBackend,
setBackendSrv,
setDataSourceSrv,
setEchoSrv,
setLocationSrv,
setQueryRunnerFactory,
setRunRequest,
@@ -79,8 +77,7 @@ import { CorrelationsService } from './core/services/CorrelationsService';
import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker';
import { backendSrv } from './core/services/backend_srv';
import { contextSrv, RedirectToUrlKey } from './core/services/context_srv';
import { Echo } from './core/services/echo/Echo';
import { reportPerformance } from './core/services/echo/EchoSrv';
import { initEchoSrv } from './core/services/echo/init';
import { KeybindingSrv } from './core/services/keybindingSrv';
import { startMeasure, stopMeasure } from './core/utils/metrics';
import { initAlerting } from './features/alerting/unified/initAlerting';
@@ -325,125 +322,6 @@ function initExtensions() {
}
}
async function initEchoSrv() {
setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' }));
window.addEventListener('load', (e) => {
const loadMetricName = 'frontend_boot_load_time_seconds';
// Metrics below are marked in public/views/index.html
const jsLoadMetricName = 'frontend_boot_js_done_time_seconds';
const cssLoadMetricName = 'frontend_boot_css_time_seconds';
if (performance) {
performance.mark(loadMetricName);
reportMetricPerformanceMark('first-paint', 'frontend_boot_', '_time_seconds');
reportMetricPerformanceMark('first-contentful-paint', 'frontend_boot_', '_time_seconds');
reportMetricPerformanceMark(loadMetricName);
reportMetricPerformanceMark(jsLoadMetricName);
reportMetricPerformanceMark(cssLoadMetricName);
}
});
if (contextSrv.user.orgRole !== '') {
const { PerformanceBackend } = await import('./core/services/echo/backends/PerformanceBackend');
registerEchoBackend(new PerformanceBackend({}));
}
if (config.grafanaJavascriptAgent.enabled) {
// Ignore Rudderstack URLs
const rudderstackUrls = [
config.rudderstackConfigUrl,
config.rudderstackDataPlaneUrl,
config.rudderstackIntegrationsUrl,
]
.filter(Boolean)
.map((url) => new RegExp(`${url}.*.`));
const { GrafanaJavascriptAgentBackend } = await import(
'./core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend'
);
registerEchoBackend(
new GrafanaJavascriptAgentBackend({
...config.grafanaJavascriptAgent,
app: {
version: config.buildInfo.version,
environment: config.buildInfo.env,
},
buildInfo: config.buildInfo,
user: {
id: String(contextSrv.user?.id),
email: contextSrv.user?.email,
},
ignoreUrls: rudderstackUrls,
})
);
}
if (config.googleAnalyticsId) {
const { GAEchoBackend } = await import('./core/services/echo/backends/analytics/GABackend');
registerEchoBackend(
new GAEchoBackend({
googleAnalyticsId: config.googleAnalyticsId,
})
);
}
if (config.googleAnalytics4Id) {
const { GA4EchoBackend } = await import('./core/services/echo/backends/analytics/GA4Backend');
registerEchoBackend(
new GA4EchoBackend({
googleAnalyticsId: config.googleAnalytics4Id,
googleAnalytics4SendManualPageViews: config.googleAnalytics4SendManualPageViews,
})
);
}
if (config.rudderstackWriteKey && config.rudderstackDataPlaneUrl) {
const { RudderstackBackend } = await import('./core/services/echo/backends/analytics/RudderstackBackend');
registerEchoBackend(
new RudderstackBackend({
writeKey: config.rudderstackWriteKey,
dataPlaneUrl: config.rudderstackDataPlaneUrl,
user: contextSrv.user,
sdkUrl: config.rudderstackSdkUrl,
configUrl: config.rudderstackConfigUrl,
integrationsUrl: config.rudderstackIntegrationsUrl,
buildInfo: config.buildInfo,
})
);
}
if (config.applicationInsightsConnectionString) {
const { ApplicationInsightsBackend } = await import(
'./core/services/echo/backends/analytics/ApplicationInsightsBackend'
);
registerEchoBackend(
new ApplicationInsightsBackend({
connectionString: config.applicationInsightsConnectionString,
endpointUrl: config.applicationInsightsEndpointUrl,
})
);
}
if (config.analyticsConsoleReporting) {
const { BrowserConsoleBackend } = await import('./core/services/echo/backends/analytics/BrowseConsoleBackend');
registerEchoBackend(new BrowserConsoleBackend());
}
}
/**
* Report when a metric of a given name was marked during the document lifecycle. Works for markers with no duration,
* like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint)
*/
function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void {
const metric = performance.getEntriesByName(metricName).at(0);
if (metric) {
const metricName = metric.name.replace(/-/g, '_');
reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000);
}
}
function handleRedirectTo(): void {
const queryParams = locationService.getSearch();
const redirectToParamKey = 'redirectTo';
@@ -150,7 +150,7 @@ export const getLoginStyles = (theme: GrafanaTheme2) => {
justifyContent: 'flex-start',
zIndex: 1,
minHeight: 320,
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.lg,
padding: theme.spacing(2, 0),
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
@@ -86,7 +86,7 @@ export function useFoldersQueryAppPlatform({
return;
}
const args = { folder: finalParentUid, type: 'folder' };
const args = { folder: finalParentUid, type: 'folder' } as const;
// Make a request
const subscription = dispatch(dashboardAPIv0alpha1.endpoints.getSearch.initiate(args));
@@ -34,7 +34,7 @@ export const getStyles = (theme: GrafanaTheme2) => ({
badge: css({
...theme.typography.bodySmall,
backgroundColor: theme.v1.palette.gray1,
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.sm,
color: theme.v1.palette.white,
display: 'inline-block',
height: '20px',
+182
View File
@@ -0,0 +1,182 @@
import { config, registerEchoBackend, setEchoSrv } from '@grafana/runtime';
import { reportMetricPerformanceMark } from 'app/core/utils/metrics';
import { contextSrv } from '../context_srv';
import { Echo } from './Echo';
// Initialise EchoSrv backends, calls during frontend app startup
export async function initEchoSrv() {
setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' }));
window.addEventListener('load', (e) => {
const loadMetricName = 'frontend_boot_load_time_seconds';
// Metrics below are marked in public/views/index.html
const jsLoadMetricName = 'frontend_boot_js_done_time_seconds';
const cssLoadMetricName = 'frontend_boot_css_time_seconds';
if (performance) {
performance.mark(loadMetricName);
reportMetricPerformanceMark('first-paint', 'frontend_boot_', '_time_seconds');
reportMetricPerformanceMark('first-contentful-paint', 'frontend_boot_', '_time_seconds');
reportMetricPerformanceMark(loadMetricName);
reportMetricPerformanceMark(jsLoadMetricName);
reportMetricPerformanceMark(cssLoadMetricName);
}
});
try {
await initPerformanceBackend();
} catch (error) {
console.error('Error initializing EchoSrv Performance backend', error);
}
try {
await initFaroBackend();
} catch (error) {
console.error('Error initializing EchoSrv Faro backend', error);
}
try {
await initGoogleAnalyticsBackend();
} catch (error) {
console.error('Error initializing EchoSrv GoogleAnalytics backend', error);
}
try {
await initGoogleAnalaytics4Backend();
} catch (error) {
console.error('Error initializing EchoSrv GoogleAnalaytics4 backend', error);
}
try {
await initRudderstackBackend();
} catch (error) {
console.error('Error initializing EchoSrv Rudderstack backend', error);
}
try {
await initAzureAppInsightsBackend();
} catch (error) {
console.error('Error initializing EchoSrv AzureAppInsights backend', error);
}
try {
await initConsoleBackend();
} catch (error) {
console.error('Error initializing EchoSrv Console backend', error);
}
}
async function initPerformanceBackend() {
if (contextSrv.user.orgRole === '') {
return;
}
const { PerformanceBackend } = await import('./backends/PerformanceBackend');
registerEchoBackend(new PerformanceBackend({}));
}
async function initFaroBackend() {
if (!config.grafanaJavascriptAgent.enabled) {
return;
}
// Ignore Rudderstack URLs
const rudderstackUrls = [
config.rudderstackConfigUrl,
config.rudderstackDataPlaneUrl,
config.rudderstackIntegrationsUrl,
]
.filter(Boolean)
.map((url) => new RegExp(`${url}.*.`));
const { GrafanaJavascriptAgentBackend } = await import(
'./backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend'
);
registerEchoBackend(
new GrafanaJavascriptAgentBackend({
...config.grafanaJavascriptAgent,
app: {
version: config.buildInfo.version,
environment: config.buildInfo.env,
},
buildInfo: config.buildInfo,
user: {
id: String(contextSrv.user?.id),
email: contextSrv.user?.email,
},
ignoreUrls: rudderstackUrls,
})
);
}
async function initGoogleAnalyticsBackend() {
if (!config.googleAnalyticsId) {
return;
}
const { GAEchoBackend } = await import('./backends/analytics/GABackend');
registerEchoBackend(
new GAEchoBackend({
googleAnalyticsId: config.googleAnalyticsId,
})
);
}
async function initGoogleAnalaytics4Backend() {
if (!config.googleAnalytics4Id) {
return;
}
const { GA4EchoBackend } = await import('./backends/analytics/GA4Backend');
registerEchoBackend(
new GA4EchoBackend({
googleAnalyticsId: config.googleAnalytics4Id,
googleAnalytics4SendManualPageViews: config.googleAnalytics4SendManualPageViews,
})
);
}
async function initRudderstackBackend() {
if (!(config.rudderstackWriteKey && config.rudderstackDataPlaneUrl)) {
return;
}
const { RudderstackBackend } = await import('./backends/analytics/RudderstackBackend');
registerEchoBackend(
new RudderstackBackend({
writeKey: config.rudderstackWriteKey,
dataPlaneUrl: config.rudderstackDataPlaneUrl,
user: contextSrv.user,
sdkUrl: config.rudderstackSdkUrl,
configUrl: config.rudderstackConfigUrl,
integrationsUrl: config.rudderstackIntegrationsUrl,
buildInfo: config.buildInfo,
})
);
}
async function initAzureAppInsightsBackend() {
if (!config.applicationInsightsConnectionString) {
return;
}
const { ApplicationInsightsBackend } = await import('./backends/analytics/ApplicationInsightsBackend');
registerEchoBackend(
new ApplicationInsightsBackend({
connectionString: config.applicationInsightsConnectionString,
endpointUrl: config.applicationInsightsEndpointUrl,
})
);
}
async function initConsoleBackend() {
if (!config.analyticsConsoleReporting) {
return;
}
const { BrowserConsoleBackend } = await import('./backends/analytics/BrowseConsoleBackend');
registerEchoBackend(new BrowserConsoleBackend());
}
+12
View File
@@ -35,3 +35,15 @@ export function stopMeasure(eventName: string) {
return;
}
}
/**
* Report when a metric of a given name was marked during the document lifecycle. Works for markers with no duration,
* like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint)
*/
export function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void {
const metric = performance.getEntriesByName(metricName).at(0);
if (metric) {
const metricName = metric.name.replace(/-/g, '_');
reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000);
}
}
@@ -91,7 +91,7 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1.5),
borderRadius: theme.shape.radius.default,
borderRadius: theme.shape.radius.lg,
marginTop: theme.spacing(3),
strong: {
color: theme.colors.text.primary,

Some files were not shown because too many files have changed in this diff Show More