Merge branch 'main' into kristina/static-transform-refIds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
+21
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
+74
@@ -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);
|
||||
});
|
||||
});
|
||||
+49
-50
@@ -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) : [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
+1
-1
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM `grafana`.`team`
|
||||
WHERE uid = 'team-1'
|
||||
Vendored
Executable
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "grafana"."team"
|
||||
WHERE uid = 'team-1'
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "grafana"."team"
|
||||
WHERE uid = 'team-1'
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user