Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c0e7ace8 | |||
| 919308f835 |
+1
-2
@@ -520,7 +520,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad
|
||||
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
|
||||
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad
|
||||
/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dashboards-squad
|
||||
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
|
||||
|
||||
# Packages
|
||||
@@ -956,7 +956,6 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
|
||||
/public/app/features/notifications/ @grafana/grafana-search-navigate-organise
|
||||
/public/app/features/org/ @grafana/grafana-search-navigate-organise
|
||||
/public/app/features/panel/ @grafana/dashboards-squad
|
||||
/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @grafana/dataviz-squad
|
||||
/public/app/features/panel/suggestions/ @grafana/dataviz-squad
|
||||
/public/app/features/playlist/ @grafana/dashboards-squad
|
||||
/public/app/features/plugins/ @grafana/plugins-platform-frontend
|
||||
|
||||
@@ -133,6 +133,12 @@ type ExportJobOptions struct {
|
||||
// FIXME: we should validate this in admission hooks
|
||||
// Prefix in target file system
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Resources to export
|
||||
// This option has been created because currently the frontend does not use
|
||||
// standarized app platform APIs. For performance and API consistency reasons, the preferred option
|
||||
// is it to use the resources.
|
||||
Resources []ResourceRef `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type MigrateJobOptions struct {
|
||||
|
||||
@@ -88,6 +88,11 @@ func (in *ErrorDetails) DeepCopy() *ErrorDetails {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) {
|
||||
*out = *in
|
||||
if in.Resources != nil {
|
||||
in, out := &in.Resources, &out.Resources
|
||||
*out = make([]ResourceRef, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -430,7 +435,7 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) {
|
||||
if in.Push != nil {
|
||||
in, out := &in.Push, &out.Push
|
||||
*out = new(ExportJobOptions)
|
||||
**out = **in
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Pull != nil {
|
||||
in, out := &in.Pull, &out.Pull
|
||||
|
||||
@@ -258,9 +258,25 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"resources": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ExportJobOptions,Resources
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors
|
||||
|
||||
+18
-4
@@ -7,10 +7,11 @@ package v0alpha1
|
||||
// ExportJobOptionsApplyConfiguration represents a declarative configuration of the ExportJobOptions type for use
|
||||
// with apply.
|
||||
type ExportJobOptionsApplyConfiguration struct {
|
||||
Message *string `json:"message,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
Branch *string `json:"branch,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
Branch *string `json:"branch,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Resources []ResourceRefApplyConfiguration `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// ExportJobOptionsApplyConfiguration constructs a declarative configuration of the ExportJobOptions type for use with
|
||||
@@ -50,3 +51,16 @@ func (b *ExportJobOptionsApplyConfiguration) WithPath(value string) *ExportJobOp
|
||||
b.Path = &value
|
||||
return b
|
||||
}
|
||||
|
||||
// WithResources adds the given value to the Resources field in the declarative configuration
|
||||
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
|
||||
// If called multiple times, values provided by each call will be appended to the Resources field.
|
||||
func (b *ExportJobOptionsApplyConfiguration) WithResources(values ...*ResourceRefApplyConfiguration) *ExportJobOptionsApplyConfiguration {
|
||||
for i := range values {
|
||||
if values[i] == nil {
|
||||
panic("nil value passed to WithResources")
|
||||
}
|
||||
b.Resources = append(b.Resources, *values[i])
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
// ValidateJob performs validation on the Job specification and returns an error if validation fails
|
||||
@@ -99,6 +100,40 @@ func validateExportJobOptions(opts *provisioning.ExportJobOptions) field.ErrorLi
|
||||
}
|
||||
}
|
||||
|
||||
// Validate resources if specified
|
||||
if len(opts.Resources) > 0 {
|
||||
for i, r := range opts.Resources {
|
||||
resourcePath := field.NewPath("spec", "push", "resources").Index(i)
|
||||
|
||||
// Validate required fields
|
||||
if r.Name == "" {
|
||||
list = append(list, field.Required(resourcePath.Child("name"), "resource name is required"))
|
||||
}
|
||||
if r.Kind == "" {
|
||||
list = append(list, field.Required(resourcePath.Child("kind"), "resource kind is required"))
|
||||
}
|
||||
if r.Group == "" {
|
||||
list = append(list, field.Required(resourcePath.Child("group"), "resource group is required"))
|
||||
}
|
||||
|
||||
// Validate that folders are not allowed
|
||||
if r.Kind == resources.FolderKind.Kind || r.Group == resources.FolderResource.Group {
|
||||
list = append(list, field.Invalid(resourcePath, r, "folders are not supported for export"))
|
||||
continue // Skip further validation for folders
|
||||
}
|
||||
|
||||
// Validate that only supported resources are allowed
|
||||
// Currently only Dashboard resources are supported (folders are rejected above)
|
||||
if r.Kind != "" && r.Group != "" {
|
||||
// Check if it's a Dashboard resource
|
||||
isDashboard := r.Group == resources.DashboardResource.Group && r.Kind == "Dashboard"
|
||||
if !isDashboard {
|
||||
list = append(list, field.Invalid(resourcePath, r, "resource type is not supported for export"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,242 @@ func TestValidateJob(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "push action with valid dashboard resources",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "dashboard-1",
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
{
|
||||
Name: "dashboard-2",
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
Path: "dashboards/",
|
||||
Message: "Export dashboards",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "push action with resource missing name",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0].name")
|
||||
require.Contains(t, err.Error(), "Required value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with resource missing kind",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "dashboard-1",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0].kind")
|
||||
require.Contains(t, err.Error(), "Required value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with resource missing group",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "dashboard-1",
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0].group")
|
||||
require.Contains(t, err.Error(), "Required value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with folder resource by kind",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "my-folder",
|
||||
Kind: "Folder",
|
||||
Group: "folder.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0]")
|
||||
require.Contains(t, err.Error(), "folders are not supported for export")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with folder resource by group",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "my-folder",
|
||||
Kind: "SomeKind",
|
||||
Group: "folder.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0]")
|
||||
require.Contains(t, err.Error(), "folders are not supported for export")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with unsupported resource type",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "my-resource",
|
||||
Kind: "AlertRule",
|
||||
Group: "alerting.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[0]")
|
||||
require.Contains(t, err.Error(), "resource type is not supported for export")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push action with valid folder (old behavior)",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Folder: "my-folder",
|
||||
Path: "dashboards/",
|
||||
Message: "Export folder",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "push action with multiple resources including invalid ones",
|
||||
job: &provisioning.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-job",
|
||||
},
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Repository: "test-repo",
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: "dashboard-1",
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
{
|
||||
Name: "my-folder",
|
||||
Kind: "Folder",
|
||||
Group: "folder.grafana.app",
|
||||
},
|
||||
{
|
||||
Name: "dashboard-2",
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateError: func(t *testing.T, err error) {
|
||||
require.Contains(t, err.Error(), "spec.push.resources[1]")
|
||||
require.Contains(t, err.Error(), "folders are not supported for export")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -288,18 +288,18 @@ func (r *localRepository) calculateFileHash(path string) (string, int64, error)
|
||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error {
|
||||
func (r *localRepository) Create(ctx context.Context, filePath string, ref string, data []byte, comment string) error {
|
||||
if err := r.validateRequest(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fpath := safepath.Join(r.path, filepath)
|
||||
fpath := safepath.Join(r.path, filePath)
|
||||
_, err := os.Stat(fpath)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
if err != nil {
|
||||
return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
|
||||
}
|
||||
return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath)
|
||||
return apierrors.NewAlreadyExists(schema.GroupResource{}, filePath)
|
||||
}
|
||||
|
||||
if safepath.IsDir(fpath) {
|
||||
@@ -314,7 +314,7 @@ func (r *localRepository) Create(ctx context.Context, filepath string, ref strin
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
|
||||
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []b
|
||||
return os.MkdirAll(fpath, 0700)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
|
||||
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ The following documentation will help you get started working with Prometheus an
|
||||
- [Configure the Prometheus data source](ref:configure-prometheus-data-source)
|
||||
- [Prometheus query editor](query-editor/)
|
||||
- [Template variables](template-variables/)
|
||||
- [Troubleshooting](troubleshooting/)
|
||||
|
||||
## Exemplars
|
||||
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../data-sources/prometheus/troubleshooting/
|
||||
description: Troubleshooting the Prometheus data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- prometheus
|
||||
- troubleshooting
|
||||
- errors
|
||||
- promql
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot Prometheus data source issues
|
||||
weight: 600
|
||||
---
|
||||
|
||||
# Troubleshoot Prometheus data source issues
|
||||
|
||||
This document provides troubleshooting information for common errors you may encounter when using the Prometheus data source in Grafana.
|
||||
|
||||
## Connection errors
|
||||
|
||||
The following errors occur when Grafana cannot establish or maintain a connection to Prometheus.
|
||||
|
||||
### Failed to connect to Prometheus
|
||||
|
||||
**Error message:** "There was an error returned querying the Prometheus API"
|
||||
|
||||
**Cause:** Grafana cannot establish a network connection to the Prometheus server.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the Prometheus server URL is correct in the data source configuration.
|
||||
1. Check that Prometheus is running and accessible from the Grafana server.
|
||||
1. Ensure the URL includes the protocol (`http://` or `https://`).
|
||||
1. Verify the port is correct (the Prometheus default port is `9090`).
|
||||
1. Ensure there are no firewall rules blocking the connection.
|
||||
1. If Grafana and Prometheus are running in separate containers, use the container IP address or hostname instead of `localhost`.
|
||||
1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your Prometheus instance is not publicly accessible.
|
||||
|
||||
### Request timed out
|
||||
|
||||
**Error message:** "context deadline exceeded" or "request timeout"
|
||||
|
||||
**Cause:** The connection to Prometheus timed out before receiving a response.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the network latency between Grafana and Prometheus.
|
||||
1. Verify that Prometheus is not overloaded or experiencing performance issues.
|
||||
1. Increase the **Query timeout** setting in the data source configuration under **Interval behavior**.
|
||||
1. Check the [Grafana server timeout configuration](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana#timeout) for server-level timeout settings.
|
||||
1. Reduce the time range or complexity of your query.
|
||||
1. Check if any network devices (load balancers, proxies) are timing out the connection.
|
||||
|
||||
### Failed to parse data source URL
|
||||
|
||||
**Error message:** "Failed to parse data source URL"
|
||||
|
||||
**Cause:** The URL entered in the data source configuration is not valid.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the URL format is correct (for example, `http://localhost:9090` or `https://prometheus.example.com:9090`).
|
||||
1. Ensure the URL includes the protocol (`http://` or `https://`).
|
||||
1. Remove any trailing slashes or invalid characters from the URL.
|
||||
|
||||
## Authentication errors
|
||||
|
||||
The following errors occur when there are issues with authentication credentials or permissions.
|
||||
|
||||
### Unauthorized (401)
|
||||
|
||||
**Error message:** "401 Unauthorized" or "Authorization failed"
|
||||
|
||||
**Cause:** The authentication credentials are invalid or missing.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the username and password are correct if using basic authentication.
|
||||
1. Check that the authentication method selected matches your Prometheus configuration.
|
||||
1. If using a reverse proxy with authentication, verify the credentials are correct.
|
||||
1. For AWS SigV4 authentication, verify the IAM credentials and permissions. Alternatively, consider using the [Amazon Managed Service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/) for simplified AWS authentication.
|
||||
|
||||
### Forbidden (403)
|
||||
|
||||
**Error message:** "403 Forbidden" or "Access denied"
|
||||
|
||||
**Cause:** The authenticated user does not have permission to access the requested resource.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the user has read access to the Prometheus API.
|
||||
1. Check Prometheus security settings and access control configuration.
|
||||
1. If using a reverse proxy, verify the proxy is not blocking the request.
|
||||
1. For AWS Managed Prometheus, verify the IAM policy grants the required permissions. Alternatively, consider using the [Amazon Managed Service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/) for simplified AWS authentication.
|
||||
|
||||
## Query errors
|
||||
|
||||
The following errors occur when there are issues with PromQL syntax or query execution.
|
||||
|
||||
### Query syntax error
|
||||
|
||||
**Error message:** "parse error: unexpected character" or "bad_data: 1:X: parse error"
|
||||
|
||||
**Cause:** The PromQL query contains invalid syntax.
|
||||
|
||||
**Alternative cause:** A proxy between Grafana and Prometheus requires authentication. When proxy authentication fails, the proxy redirects the request to an HTML authentication page. Grafana cannot parse the HTML response, which results in a parse error. This appears to be a query issue but is actually a proxy authentication issue.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check your query syntax for typos or invalid characters.
|
||||
1. Verify that metric names and label names are valid identifiers.
|
||||
1. Ensure string values in label matchers are enclosed in quotes.
|
||||
1. Use the Prometheus expression browser to test your query directly.
|
||||
1. Refer to the [Prometheus querying documentation](https://prometheus.io/docs/prometheus/latest/querying/basics/) for syntax guidance.
|
||||
1. If you have a proxy between Grafana and Prometheus, verify that proxy authentication is correctly configured. Check your proxy logs for authentication failures or redirects.
|
||||
|
||||
### Query returns no data for a metric
|
||||
|
||||
**Symptom:** The query returns no data and the visualization is empty.
|
||||
|
||||
**Cause:** The specified metric does not exist in Prometheus, or there is no data for the selected time range.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the metric name is spelled correctly.
|
||||
1. Check that the metric is being scraped by Prometheus.
|
||||
1. Use the Prometheus API to browse available metrics at `/api/v1/label/__name__/values`.
|
||||
1. Use the [target metadata API](https://prometheus.io/docs/prometheus/latest/querying/api#querying-target-metadata) to verify which metrics a target exposes.
|
||||
1. Verify the time range includes data for the metric.
|
||||
|
||||
### Query timeout limit exceeded
|
||||
|
||||
**Error message:** "query timed out in expression evaluation" or "query processing would load too many samples"
|
||||
|
||||
**Cause:** The query took longer than the configured timeout limit or would return too many samples.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add more specific label filters to limit the data scanned.
|
||||
1. Increase the **Query timeout** setting in the data source configuration.
|
||||
1. Use aggregation functions like `sum()`, `avg()`, or `rate()` to reduce the number of time series.
|
||||
1. Increase the `query.timeout` or `query.max-samples` settings in Prometheus if you have admin access.
|
||||
|
||||
### Too many time series
|
||||
|
||||
**Error message:** "exceeded maximum resolution of 11,000 points per timeseries" or "maximum number of series limit exceeded"
|
||||
|
||||
**Cause:** The query is returning more time series or data points than the configured limits allow.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add label filters to limit the number of time series returned.
|
||||
1. Increase the **Min interval** or **Resolution** in the query options to reduce the number of data points.
|
||||
1. Use aggregation functions to combine time series.
|
||||
1. Adjust the **Series limit** setting in the data source configuration under **Other settings**.
|
||||
|
||||
### Invalid function or aggregation
|
||||
|
||||
**Error message:** "unknown function" or "parse error: unexpected aggregation"
|
||||
|
||||
**Cause:** The query uses an invalid or unsupported PromQL function.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the function name is spelled correctly and is a valid PromQL function.
|
||||
1. Check that you are using the correct syntax for the function.
|
||||
1. Ensure your Prometheus version supports the function you are using.
|
||||
1. Refer to the [PromQL functions documentation](https://prometheus.io/docs/prometheus/latest/querying/functions/) for available functions.
|
||||
|
||||
## Configuration errors
|
||||
|
||||
The following errors occur when the data source is not configured correctly.
|
||||
|
||||
### Invalid Prometheus type
|
||||
|
||||
**Error message:** Unexpected behavior when querying metrics or labels
|
||||
|
||||
**Cause:** The **Prometheus type** setting does not match your actual Prometheus-compatible database.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Open the data source configuration in Grafana.
|
||||
1. Under **Performance**, select the correct **Prometheus type** (Prometheus, Cortex, Mimir, or Thanos).
|
||||
1. Different database types support different APIs, so setting this incorrectly may cause unexpected behavior.
|
||||
|
||||
### Scrape interval mismatch
|
||||
|
||||
**Error message:** Data appears sparse or aggregated incorrectly
|
||||
|
||||
**Cause:** The **Scrape interval** setting in Grafana does not match the actual scrape interval in Prometheus.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check your Prometheus configuration file for the `scrape_interval` setting.
|
||||
1. Update the **Scrape interval** in the Grafana data source configuration under **Interval behavior** to match.
|
||||
1. If the Grafana interval is higher than the Prometheus interval, you may see less data points than expected.
|
||||
|
||||
## TLS and certificate errors
|
||||
|
||||
The following errors occur when there are issues with TLS configuration.
|
||||
|
||||
### Certificate verification failed
|
||||
|
||||
**Error message:** "x509: certificate signed by unknown authority" or "certificate verify failed"
|
||||
|
||||
**Cause:** Grafana cannot verify the TLS certificate presented by Prometheus.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. If using a self-signed certificate, enable **Add self-signed certificate** in the TLS settings and add your CA certificate.
|
||||
1. Verify the certificate chain is complete and valid.
|
||||
1. Ensure the certificate has not expired.
|
||||
1. As a temporary workaround for testing, enable **Skip TLS verify** (not recommended for production).
|
||||
|
||||
### TLS handshake error
|
||||
|
||||
**Error message:** "TLS: handshake failure" or "connection reset"
|
||||
|
||||
**Cause:** The TLS handshake between Grafana and Prometheus failed.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that Prometheus is configured to use TLS.
|
||||
1. Check that the TLS version and cipher suites are compatible.
|
||||
1. If using client certificates, ensure they are correctly configured in the **TLS client authentication** section.
|
||||
1. Verify the server name matches the certificate's Common Name or Subject Alternative Name.
|
||||
|
||||
## Other common issues
|
||||
|
||||
The following issues don't produce specific error messages but are commonly encountered.
|
||||
|
||||
### Empty query results
|
||||
|
||||
**Cause:** The query returns no data.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the time range includes data in Prometheus.
|
||||
1. Check that the metric and label names are correct.
|
||||
1. Test the query directly in the Prometheus expression browser.
|
||||
1. Ensure label filters are not excluding all data.
|
||||
1. For rate or increase functions, ensure the time range is at least twice the scrape interval.
|
||||
|
||||
### Slow query performance
|
||||
|
||||
**Cause:** Queries take a long time to execute.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add more specific label filters to limit the data scanned.
|
||||
1. Increase the **Min interval** in the query options.
|
||||
1. Check Prometheus server performance and resource utilization.
|
||||
1. Enable **Disable metrics lookup** in the data source configuration for large Prometheus instances.
|
||||
1. Enable **Incremental querying (beta)** to cache query results.
|
||||
1. Consider using recording rules to pre-aggregate frequently queried data.
|
||||
|
||||
### Data appears delayed or missing recent points
|
||||
|
||||
**Cause:** The visualization doesn't show the most recent data.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the dashboard time range and refresh settings.
|
||||
1. Verify the **Scrape interval** is configured correctly.
|
||||
1. Ensure Prometheus has finished scraping the target.
|
||||
1. Check for clock synchronization issues between Grafana and Prometheus.
|
||||
1. For `rate()` and similar functions, remember that they need at least two data points to calculate.
|
||||
|
||||
### Exemplars not showing
|
||||
|
||||
**Cause:** Exemplar data is not appearing in visualizations.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that exemplars are enabled in the data source configuration under **Exemplars**.
|
||||
1. Check that your Prometheus version supports exemplars (2.26+).
|
||||
1. Ensure your instrumented application is sending exemplar data.
|
||||
1. Verify the tracing data source is correctly configured for the exemplar link.
|
||||
1. Enable the **Exemplars** toggle in the query editor.
|
||||
|
||||
### Alerting rules not visible
|
||||
|
||||
**Cause:** Prometheus alerting rules are not appearing in the Grafana Alerting UI.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that **Manage alerts via Alerting UI** is enabled in the data source configuration.
|
||||
1. Check that Prometheus has alerting rules configured.
|
||||
1. Ensure Grafana can access the Prometheus rules API endpoint.
|
||||
1. Note that for Prometheus (unlike Mimir), the Alerting UI only supports viewing existing rules, not creating new ones.
|
||||
|
||||
## Get additional help
|
||||
|
||||
If you continue to experience issues after following this troubleshooting guide:
|
||||
|
||||
1. Check the [Prometheus documentation](https://prometheus.io/docs/) for API and PromQL guidance.
|
||||
1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues.
|
||||
1. Contact Grafana Support if you are a Cloud Pro, Cloud Contracted, or Enterprise user.
|
||||
1. When reporting issues, include:
|
||||
- Grafana version
|
||||
- Prometheus version and type (Prometheus, Mimir, Cortex, Thanos)
|
||||
- Error messages (redact sensitive information)
|
||||
- Steps to reproduce
|
||||
- Relevant configuration such as data source settings, query timeout, and TLS settings (redact tokens, passwords, and other credentials)
|
||||
+2
@@ -1108,6 +1108,8 @@ export type ExportJobOptions = {
|
||||
message?: string;
|
||||
/** FIXME: we should validate this in admission hooks Prefix in target file system */
|
||||
path?: string;
|
||||
/** Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources. */
|
||||
resources?: ResourceRef[];
|
||||
};
|
||||
export type JobSpec = {
|
||||
/** Possible enum values:
|
||||
|
||||
@@ -119,14 +119,7 @@ describe('Get y range', () => {
|
||||
values: [2, 1.999999999999999, 2.000000000000001, 2, 2],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: { range: { min: 1.9999999999999999999, max: 2.000000000000000001, delta: 0 } },
|
||||
};
|
||||
const decimalsNotCloseYField: Field = {
|
||||
name: 'y',
|
||||
values: [2, 0.0094, 0.0053, 0.0078, 0.0061],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: { range: { min: 0.0053, max: 0.0094, delta: 0.0041 } },
|
||||
state: { range: { min: 1.999999999999999, max: 2.000000000000001, delta: 0 } },
|
||||
};
|
||||
const xField: Field = {
|
||||
name: 'x',
|
||||
@@ -190,11 +183,6 @@ describe('Get y range', () => {
|
||||
field: decimalsCloseYField,
|
||||
expected: [2, 4],
|
||||
},
|
||||
{
|
||||
description: 'decimal values which are not close to equal should not be rounded out',
|
||||
field: decimalsNotCloseYField,
|
||||
expected: [0.0053, 0.0094],
|
||||
},
|
||||
])(`should return correct range for $description`, ({ field, expected }) => {
|
||||
const actual = getYRange(getAlignedFrame(field));
|
||||
expect(actual).toEqual(expected);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FieldType,
|
||||
getFieldColorModeForField,
|
||||
GrafanaTheme2,
|
||||
guessDecimals,
|
||||
isLikelyAscendingVector,
|
||||
nullToValue,
|
||||
roundDecimals,
|
||||
@@ -77,6 +76,8 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax {
|
||||
min = Math.min(min!, field.config.min ?? Infinity);
|
||||
max = Math.max(max!, field.config.max ?? -Infinity);
|
||||
|
||||
// console.log({ min, max });
|
||||
|
||||
// if noValue is set, ensure that it is included in the range as well
|
||||
const noValue = +field.config?.noValue!;
|
||||
if (!Number.isNaN(noValue)) {
|
||||
@@ -84,11 +85,9 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax {
|
||||
max = Math.max(max, noValue);
|
||||
}
|
||||
|
||||
const decimals = field.config.decimals ?? Math.max(guessDecimals(min), guessDecimals(max));
|
||||
|
||||
// call roundDecimals to mirror what is going to eventually happen in uplot
|
||||
let roundedMin = roundDecimals(min, decimals);
|
||||
let roundedMax = roundDecimals(max, decimals);
|
||||
let roundedMin = roundDecimals(min, field.config.decimals ?? 0);
|
||||
let roundedMax = roundDecimals(max, field.config.decimals ?? 0);
|
||||
|
||||
// if the rounded min and max are different,
|
||||
// we can return the real min and max.
|
||||
@@ -103,9 +102,11 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax {
|
||||
roundedMax = 1;
|
||||
} else if (roundedMin < 0) {
|
||||
// both are negative
|
||||
// max = 0;
|
||||
roundedMin *= 2;
|
||||
} else {
|
||||
// both are positive
|
||||
// min = 0;
|
||||
roundedMax *= 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
@@ -23,8 +24,58 @@ import (
|
||||
// The response status indicates the original stored version, so we can then request it in an un-converted form
|
||||
type conversionShim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error)
|
||||
|
||||
// createDashboardConversionShim creates a conversion shim for dashboards that preserves the original API version.
|
||||
// It uses a provided versionClients cache to allow sharing across multiple shim calls.
|
||||
func createDashboardConversionShim(ctx context.Context, clients resources.ResourceClients, gvr schema.GroupVersionResource, versionClients map[string]dynamic.ResourceInterface) conversionShim {
|
||||
shim := func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||
// Check if there's a stored version in the conversion status.
|
||||
// This indicates the original API version the dashboard was created with,
|
||||
// which should be preserved during export regardless of whether conversion succeeded or failed.
|
||||
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
|
||||
if storedVersion != "" {
|
||||
// For v0 we can simply fallback -- the full model is saved
|
||||
if strings.HasPrefix(storedVersion, "v0") {
|
||||
item.SetAPIVersion(fmt.Sprintf("%s/%s", gvr.Group, storedVersion))
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// For any other version (v1, v2, v3, etc.), fetch the original version via client
|
||||
// Check if we already have a client cached for this version
|
||||
versionClient, ok := versionClients[storedVersion]
|
||||
if !ok {
|
||||
// Dynamically construct the GroupVersionResource for any version
|
||||
versionGVR := schema.GroupVersionResource{
|
||||
Group: gvr.Group,
|
||||
Version: storedVersion,
|
||||
Resource: gvr.Resource,
|
||||
}
|
||||
var err error
|
||||
versionClient, _, err = clients.ForResource(ctx, versionGVR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
|
||||
}
|
||||
versionClients[storedVersion] = versionClient
|
||||
}
|
||||
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
|
||||
}
|
||||
|
||||
// If conversion failed but there's no storedVersion, this is an error condition
|
||||
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
|
||||
if failed {
|
||||
return nil, fmt.Errorf("conversion failed but no storedVersion available")
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
return shim
|
||||
}
|
||||
|
||||
func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
|
||||
progress.SetMessage(ctx, "start resource export")
|
||||
|
||||
// Create a shared versionClients map for dashboard conversion caching
|
||||
versionClients := make(map[string]dynamic.ResourceInterface)
|
||||
|
||||
for _, kind := range resources.SupportedProvisioningResources {
|
||||
// skip from folders as we do them first... so only dashboards
|
||||
if kind == resources.FolderResource {
|
||||
@@ -38,50 +89,10 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
|
||||
}
|
||||
|
||||
// When requesting dashboards over the v1 api, we want to keep the original apiVersion if conversion fails
|
||||
// Always use the cache version to share clients across all dashboard exports
|
||||
var shim conversionShim
|
||||
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
|
||||
// Cache clients for different versions
|
||||
versionClients := make(map[string]dynamic.ResourceInterface)
|
||||
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||
// Check if there's a stored version in the conversion status.
|
||||
// This indicates the original API version the dashboard was created with,
|
||||
// which should be preserved during export regardless of whether conversion succeeded or failed.
|
||||
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
|
||||
if storedVersion != "" {
|
||||
// For v0 we can simply fallback -- the full model is saved
|
||||
if strings.HasPrefix(storedVersion, "v0") {
|
||||
item.SetAPIVersion(fmt.Sprintf("%s/%s", kind.Group, storedVersion))
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// For any other version (v1, v2, v3, etc.), fetch the original version via client
|
||||
// Check if we already have a client cached for this version
|
||||
versionClient, ok := versionClients[storedVersion]
|
||||
if !ok {
|
||||
// Dynamically construct the GroupVersionResource for any version
|
||||
versionGVR := schema.GroupVersionResource{
|
||||
Group: kind.Group,
|
||||
Version: storedVersion,
|
||||
Resource: kind.Resource,
|
||||
}
|
||||
var err error
|
||||
versionClient, _, err = clients.ForResource(ctx, versionGVR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
|
||||
}
|
||||
versionClients[storedVersion] = versionClient
|
||||
}
|
||||
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
|
||||
}
|
||||
|
||||
// If conversion failed but there's no storedVersion, this is an error condition
|
||||
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
|
||||
if failed {
|
||||
return nil, fmt.Errorf("conversion failed but no storedVersion available")
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
shim = createDashboardConversionShim(ctx, clients, kind, versionClients)
|
||||
}
|
||||
|
||||
if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
|
||||
@@ -92,6 +103,320 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportSpecificResources exports a list of specific resources identified by ResourceRef entries.
|
||||
// It validates that resources are not folders, are supported, and are unmanaged.
|
||||
// Note: The caller must validate that the repository has a folder sync target before calling this function.
|
||||
func ExportSpecificResources(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
|
||||
if len(options.Resources) == 0 {
|
||||
return errors.New("no resources specified for export")
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, "exporting specific resources")
|
||||
|
||||
tree, err := loadUnmanagedFolderTree(ctx, clients, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a shared dashboard conversion shim and cache for all dashboard resources
|
||||
// Create the versionClients map once so it's shared across all dashboard conversion calls
|
||||
var dashboardShim conversionShim
|
||||
versionClients := make(map[string]dynamic.ResourceInterface)
|
||||
|
||||
for _, resourceRef := range options.Resources {
|
||||
if err := exportSingleResource(ctx, resourceRef, options, clients, repositoryResources, tree, &dashboardShim, versionClients, progress); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadUnmanagedFolderTree loads all unmanaged folders into a tree structure.
|
||||
// This is needed to resolve folder paths for resources when exporting.
|
||||
func loadUnmanagedFolderTree(ctx context.Context, clients resources.ResourceClients, progress jobs.JobProgressRecorder) (resources.FolderTree, error) {
|
||||
progress.SetMessage(ctx, "loading folder tree from API server")
|
||||
folderClient, err := clients.Folder(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get folder client: %w", err)
|
||||
}
|
||||
|
||||
tree := resources.NewEmptyFolderTree()
|
||||
if err := resources.ForEach(ctx, folderClient, func(item *unstructured.Unstructured) error {
|
||||
if tree.Count() >= resources.MaxNumberOfFolders {
|
||||
return errors.New("too many folders")
|
||||
}
|
||||
meta, err := utils.MetaAccessor(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract meta accessor: %w", err)
|
||||
}
|
||||
|
||||
manager, _ := meta.GetManagerProperties()
|
||||
// Skip if already managed by any manager (repository, file provisioning, etc.)
|
||||
if manager.Identity != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tree.AddUnstructured(item)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("load folder tree: %w", err)
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// exportSingleResource exports a single resource, handling validation, fetching, conversion, and writing.
|
||||
func exportSingleResource(
|
||||
ctx context.Context,
|
||||
resourceRef provisioning.ResourceRef,
|
||||
options provisioning.ExportJobOptions,
|
||||
clients resources.ResourceClients,
|
||||
repositoryResources resources.RepositoryResources,
|
||||
tree resources.FolderTree,
|
||||
dashboardShim *conversionShim,
|
||||
versionClients map[string]dynamic.ResourceInterface,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) error {
|
||||
result := jobs.JobResourceResult{
|
||||
Name: resourceRef.Name,
|
||||
Group: resourceRef.Group,
|
||||
Kind: resourceRef.Kind,
|
||||
Action: repository.FileActionCreated,
|
||||
}
|
||||
|
||||
gvk := schema.GroupVersionKind{
|
||||
Group: resourceRef.Group,
|
||||
Kind: resourceRef.Kind,
|
||||
// Version is left empty so ForKind will use the preferred version
|
||||
}
|
||||
|
||||
// Validate resource reference
|
||||
if err := validateResourceRef(gvk, &result, progress, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != nil {
|
||||
// Validation failed, but we continue processing other resources
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get client and fetch resource
|
||||
progress.SetMessage(ctx, fmt.Sprintf("Fetching resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
|
||||
client, gvr, err := clients.ForKind(ctx, gvk)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("get client for %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
|
||||
progress.Record(ctx, result)
|
||||
return progress.TooManyErrors()
|
||||
}
|
||||
|
||||
// Validate resource type is supported
|
||||
if err := validateResourceType(gvr, &result, progress, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch and validate the resource
|
||||
item, meta, err := fetchAndValidateResource(ctx, client, resourceRef, gvr, &result, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert dashboard if needed
|
||||
item, meta, err = convertDashboardIfNeeded(ctx, gvr, item, meta, clients, dashboardShim, versionClients, resourceRef, &result, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute export path from folder tree
|
||||
exportPath := computeExportPath(options.Path, meta, tree)
|
||||
|
||||
// Export the resource
|
||||
return writeResourceToRepository(ctx, item, meta, exportPath, options.Branch, repositoryResources, resourceRef, &result, progress)
|
||||
}
|
||||
|
||||
// validateResourceRef validates that a resource reference is not a folder.
|
||||
func validateResourceRef(gvk schema.GroupVersionKind, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
|
||||
if gvk.Kind == resources.FolderKind.Kind || gvk.Group == resources.FolderResource.Group {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("folders are not supported for export")
|
||||
progress.Record(ctx, *result)
|
||||
return progress.TooManyErrors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateResourceType validates that a resource type is supported for export.
|
||||
func validateResourceType(gvr schema.GroupVersionResource, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
|
||||
isSupported := false
|
||||
for _, supported := range resources.SupportedProvisioningResources {
|
||||
if supported.Group == gvr.Group && supported.Resource == gvr.Resource {
|
||||
isSupported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isSupported {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("resource type %s/%s is not supported for export", gvr.Group, gvr.Resource)
|
||||
progress.Record(ctx, *result)
|
||||
return progress.TooManyErrors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAndValidateResource fetches a resource from the API server and validates it's unmanaged.
|
||||
func fetchAndValidateResource(
|
||||
ctx context.Context,
|
||||
client dynamic.ResourceInterface,
|
||||
resourceRef provisioning.ResourceRef,
|
||||
gvr schema.GroupVersionResource,
|
||||
result *jobs.JobResourceResult,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
|
||||
item, err := client.Get(ctx, resourceRef.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("get resource %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
|
||||
progress.Record(ctx, *result)
|
||||
return nil, nil, progress.TooManyErrors()
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(item)
|
||||
if err != nil {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err)
|
||||
progress.Record(ctx, *result)
|
||||
return nil, nil, progress.TooManyErrors()
|
||||
}
|
||||
|
||||
manager, _ := meta.GetManagerProperties()
|
||||
// Reject if already managed by any manager (repository, file provisioning, etc.)
|
||||
if manager.Identity != "" {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("resource %s/%s/%s is managed and cannot be exported", resourceRef.Group, resourceRef.Kind, resourceRef.Name)
|
||||
progress.Record(ctx, *result)
|
||||
return nil, nil, progress.TooManyErrors()
|
||||
}
|
||||
|
||||
return item, meta, nil
|
||||
}
|
||||
|
||||
// convertDashboardIfNeeded converts a dashboard to its original API version if needed.
|
||||
// Returns the potentially updated item and meta accessor.
|
||||
func convertDashboardIfNeeded(
|
||||
ctx context.Context,
|
||||
gvr schema.GroupVersionResource,
|
||||
item *unstructured.Unstructured,
|
||||
meta utils.GrafanaMetaAccessor,
|
||||
clients resources.ResourceClients,
|
||||
dashboardShim *conversionShim,
|
||||
versionClients map[string]dynamic.ResourceInterface,
|
||||
resourceRef provisioning.ResourceRef,
|
||||
result *jobs.JobResourceResult,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
|
||||
if gvr.GroupResource() != resources.DashboardResource.GroupResource() {
|
||||
return item, meta, nil
|
||||
}
|
||||
|
||||
// Create or reuse the dashboard shim (shared across all dashboard resources)
|
||||
// Pass the shared versionClients map to ensure client caching works correctly
|
||||
if *dashboardShim == nil {
|
||||
*dashboardShim = createDashboardConversionShim(ctx, clients, gvr, versionClients)
|
||||
}
|
||||
|
||||
var err error
|
||||
item, err = (*dashboardShim)(ctx, item)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("converting dashboard %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
|
||||
progress.Record(ctx, *result)
|
||||
return nil, nil, progress.TooManyErrors()
|
||||
}
|
||||
|
||||
// Re-extract meta after shim conversion in case the item changed
|
||||
meta, err = utils.MetaAccessor(item)
|
||||
if err != nil {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("extracting meta accessor after conversion for resource %s: %w", result.Name, err)
|
||||
progress.Record(ctx, *result)
|
||||
return nil, nil, progress.TooManyErrors()
|
||||
}
|
||||
|
||||
return item, meta, nil
|
||||
}
|
||||
|
||||
// computeExportPath computes the export path by combining the base path with the folder path from the tree.
|
||||
func computeExportPath(basePath string, meta utils.GrafanaMetaAccessor, tree resources.FolderTree) string {
|
||||
exportPath := basePath
|
||||
resourceFolder := meta.GetFolder()
|
||||
if resourceFolder != "" {
|
||||
// Get the folder path from the unmanaged tree (rootFolder is empty string for unmanaged tree)
|
||||
fid, ok := tree.DirPath(resourceFolder, "")
|
||||
if !ok {
|
||||
// Folder not found in tree - this shouldn't happen for unmanaged folders
|
||||
// but if it does, we'll just use the base path
|
||||
return exportPath
|
||||
}
|
||||
if fid.Path != "" {
|
||||
if exportPath != "" {
|
||||
exportPath = safepath.Join(exportPath, fid.Path)
|
||||
} else {
|
||||
exportPath = fid.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
return exportPath
|
||||
}
|
||||
|
||||
// writeResourceToRepository writes a resource to the repository.
|
||||
func writeResourceToRepository(
|
||||
ctx context.Context,
|
||||
item *unstructured.Unstructured,
|
||||
meta utils.GrafanaMetaAccessor,
|
||||
exportPath string,
|
||||
branch string,
|
||||
repositoryResources resources.RepositoryResources,
|
||||
resourceRef provisioning.ResourceRef,
|
||||
result *jobs.JobResourceResult,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) error {
|
||||
// Export the resource
|
||||
progress.SetMessage(ctx, fmt.Sprintf("Exporting resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
|
||||
var err error
|
||||
// exportPath already includes the folder structure from the unmanaged tree.
|
||||
// We need to clear the folder metadata so WriteResourceFileFromObject doesn't try to resolve
|
||||
// folder paths from repository tree (which doesn't have unmanaged folders).
|
||||
// When folder is empty, WriteResourceFileFromObject will use rootFolder logic:
|
||||
// - For instance targets: rootFolder is empty, so fid.Path will be empty, and it will use exportPath directly
|
||||
// - For folder targets: rootFolder is repo name, but fid.Path will still be empty, so it will use exportPath directly
|
||||
originalFolder := meta.GetFolder()
|
||||
if originalFolder != "" {
|
||||
meta.SetFolder("")
|
||||
defer func() {
|
||||
meta.SetFolder(originalFolder)
|
||||
}()
|
||||
}
|
||||
result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{
|
||||
Path: exportPath, // Path already includes folder structure from unmanaged tree
|
||||
Ref: branch,
|
||||
})
|
||||
|
||||
if errors.Is(err, resources.ErrAlreadyInRepository) {
|
||||
result.Action = repository.FileActionIgnored
|
||||
} else if err != nil {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = fmt.Errorf("writing resource file for %s: %w", result.Name, err)
|
||||
}
|
||||
|
||||
progress.Record(ctx, *result)
|
||||
return progress.TooManyErrors()
|
||||
}
|
||||
|
||||
func exportResource(ctx context.Context,
|
||||
resource string,
|
||||
options provisioning.ExportJobOptions,
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
provisioningV0 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
// createFolder creates a folder with the given Grafana UID as metadata.name and optional title
|
||||
func createFolder(grafanaUID, k8sUID, title, parentUID string) unstructured.Unstructured {
|
||||
folder := unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.FolderResource.GroupVersion().String(),
|
||||
"kind": "Folder",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": grafanaUID, // Grafana UID is stored as metadata.name
|
||||
"uid": k8sUID,
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": title,
|
||||
},
|
||||
},
|
||||
}
|
||||
if parentUID != "" {
|
||||
meta, _ := utils.MetaAccessor(&folder)
|
||||
meta.SetFolder(parentUID)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
// createDashboardWithFolder creates a dashboard in the specified folder
|
||||
func createDashboardWithFolder(name, folderUID string) unstructured.Unstructured {
|
||||
dashboard := createDashboardObject(name)
|
||||
if folderUID != "" {
|
||||
meta, _ := utils.MetaAccessor(&dashboard)
|
||||
meta.SetFolder(folderUID)
|
||||
}
|
||||
return dashboard
|
||||
}
|
||||
|
||||
func TestExportSpecificResources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMocks func(t *testing.T) (resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
|
||||
options provisioningV0.ExportJobOptions
|
||||
wantErr string
|
||||
assertResults func(t *testing.T, resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
|
||||
}{
|
||||
{
|
||||
name: "success with folder paths",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
folder := createFolder("team-a-uid", "k8s-1", "team-a", "")
|
||||
dashboard1 := createDashboardWithFolder("dashboard-1", "team-a-uid")
|
||||
dashboard2 := createDashboardObject("dashboard-2")
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
folderClient := &mockDynamicInterface{items: []unstructured.Unstructured{folder}}
|
||||
resourceClients.On("Folder", mock.Anything).Return(folderClient, nil)
|
||||
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard1}}, resources.DashboardResource, nil).Once()
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard2}}, resources.DashboardResource, nil).Once()
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
repoResources.On("WriteResourceFileFromObject", mock.Anything,
|
||||
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-1" }),
|
||||
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a" })).
|
||||
Return("grafana/team-a/dashboard-1.json", nil)
|
||||
repoResources.On("WriteResourceFileFromObject", mock.Anything,
|
||||
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-2" }),
|
||||
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana" })).
|
||||
Return("grafana/dashboard-2.json", nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "dashboard-1" && r.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "dashboard-2" && r.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil).Times(2)
|
||||
|
||||
return resourceClients, repoResources, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Path: "grafana",
|
||||
Branch: "feature/branch",
|
||||
Resources: []provisioningV0.ResourceRef{
|
||||
{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group},
|
||||
{Name: "dashboard-2", Kind: "Dashboard", Group: resources.DashboardResource.Group},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty resources returns error",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
return nil, nil, nil
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{},
|
||||
},
|
||||
wantErr: "no resources specified for export",
|
||||
},
|
||||
{
|
||||
name: "rejects folders",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "my-folder" && r.Error != nil && r.Error.Error() == "folders are not supported for export"
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, nil, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "my-folder", Kind: "Folder", Group: resources.FolderResource.Group}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rejects managed resources",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
dashboard := createDashboardObject("managed-dashboard")
|
||||
meta, _ := utils.MetaAccessor(&dashboard)
|
||||
meta.SetManagerProperties(utils.ManagerProperties{Kind: utils.ManagerKindRepo, Identity: "some-repo"})
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "managed-dashboard" && r.Error != nil && r.Error.Error() == "resource dashboard.grafana.app/Dashboard/managed-dashboard is managed and cannot be exported"
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, nil, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "managed-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rejects unsupported resources",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: "playlist.grafana.app", Kind: "Playlist"}
|
||||
gvr := schema.GroupVersionResource{Group: "playlist.grafana.app", Resource: "playlists"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, gvr, nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "some-resource" && r.Error != nil && r.Error.Error() == "resource type playlist.grafana.app/playlists is not supported for export"
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, nil, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "some-resource", Kind: "Playlist", Group: "playlist.grafana.app"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolves nested folder paths",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
parentFolder := createFolder("team-a-uid", "k8s-1", "team-a", "")
|
||||
childFolder := createFolder("subteam-uid", "k8s-2", "subteam", "team-a-uid")
|
||||
dashboard := createDashboardWithFolder("dashboard-in-nested-folder", "subteam-uid")
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{items: []unstructured.Unstructured{parentFolder, childFolder}}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
repoResources.On("WriteResourceFileFromObject", mock.Anything,
|
||||
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-in-nested-folder" }),
|
||||
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a/subteam" })).
|
||||
Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "dashboard-in-nested-folder" && r.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, repoResources, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Path: "grafana",
|
||||
Branch: "feature/branch",
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-in-nested-folder", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "folder client error",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error"))
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
|
||||
return resourceClients, nil, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
wantErr: "get folder client: folder client error",
|
||||
},
|
||||
{
|
||||
name: "resource not found",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, resources.DashboardResource, nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "non-existent-dashboard" && r.Error != nil && r.Error.Error() == "get resource dashboard.grafana.app/Dashboard/non-existent-dashboard: no items found"
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, nil, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "non-existent-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard version conversion",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
v1Dashboard := unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]interface{}{"name": "v2-dashboard"},
|
||||
"status": map[string]interface{}{
|
||||
"conversion": map[string]interface{}{"failed": true, "storedVersion": "v2alpha1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1")
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v1Dashboard}}, resources.DashboardResource, nil)
|
||||
v2GVR := schema.GroupVersionResource{Group: resources.DashboardResource.Group, Version: "v2alpha1", Resource: resources.DashboardResource.Resource}
|
||||
resourceClients.On("ForResource", mock.Anything, v2GVR).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}, gvk, nil)
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
repoResources.On("WriteResourceFileFromObject", mock.Anything,
|
||||
mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "v2-dashboard" && obj.GetAPIVersion() == "dashboard.grafana.app/v2alpha1"
|
||||
}),
|
||||
mock.Anything).Return("grafana/v2-dashboard.json", nil)
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "v2-dashboard" && r.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
|
||||
return resourceClients, repoResources, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "v2-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many errors",
|
||||
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
|
||||
dashboard := createDashboardObject("dashboard-1")
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
|
||||
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
|
||||
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything).Return("", fmt.Errorf("write error"))
|
||||
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
|
||||
return r.Name == "dashboard-1" && r.Action == repository.FileActionIgnored && r.Error != nil
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors"))
|
||||
|
||||
return resourceClients, repoResources, progress
|
||||
},
|
||||
options: provisioningV0.ExportJobOptions{
|
||||
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
|
||||
},
|
||||
wantErr: "too many errors",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resourceClients, repoResources, progress := tt.setupMocks(t)
|
||||
|
||||
err := ExportSpecificResources(context.Background(), "test-repo", tt.options, resourceClients, repoResources, progress)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.assertResults != nil {
|
||||
tt.assertResults(t, resourceClients, repoResources, progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,26 +21,29 @@ type ExportFn func(ctx context.Context, repoName string, options provisioning.Ex
|
||||
type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
|
||||
|
||||
type ExportWorker struct {
|
||||
clientFactory resources.ClientFactory
|
||||
repositoryResources resources.RepositoryResourcesFactory
|
||||
exportFn ExportFn
|
||||
wrapWithStageFn WrapWithStageFn
|
||||
metrics jobs.JobMetrics
|
||||
clientFactory resources.ClientFactory
|
||||
repositoryResources resources.RepositoryResourcesFactory
|
||||
exportAllFn ExportFn
|
||||
exportSpecificResourcesFn ExportFn
|
||||
wrapWithStageFn WrapWithStageFn
|
||||
metrics jobs.JobMetrics
|
||||
}
|
||||
|
||||
func NewExportWorker(
|
||||
clientFactory resources.ClientFactory,
|
||||
repositoryResources resources.RepositoryResourcesFactory,
|
||||
exportFn ExportFn,
|
||||
exportAllFn ExportFn,
|
||||
exportSpecificResourcesFn ExportFn,
|
||||
wrapWithStageFn WrapWithStageFn,
|
||||
metrics jobs.JobMetrics,
|
||||
) *ExportWorker {
|
||||
return &ExportWorker{
|
||||
clientFactory: clientFactory,
|
||||
repositoryResources: repositoryResources,
|
||||
exportFn: exportFn,
|
||||
wrapWithStageFn: wrapWithStageFn,
|
||||
metrics: metrics,
|
||||
clientFactory: clientFactory,
|
||||
repositoryResources: repositoryResources,
|
||||
exportAllFn: exportAllFn,
|
||||
exportSpecificResourcesFn: exportSpecificResourcesFn,
|
||||
wrapWithStageFn: wrapWithStageFn,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +103,19 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
|
||||
return fmt.Errorf("create repository resource client: %w", err)
|
||||
}
|
||||
|
||||
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
|
||||
// Check if Resources list is provided (specific resources export mode)
|
||||
if len(options.Resources) > 0 {
|
||||
progress.SetTotal(ctx, len(options.Resources))
|
||||
progress.StrictMaxErrors(1) // Fail fast on any error during export
|
||||
// Validate that specific resource export is only used with folder sync targets
|
||||
if cfg.Spec.Sync.Target != provisioning.SyncTargetTypeFolder {
|
||||
return fmt.Errorf("specific resource export is only supported for folder sync targets, but repository has target type '%s'", cfg.Spec.Sync.Target)
|
||||
}
|
||||
return r.exportSpecificResourcesFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
|
||||
}
|
||||
|
||||
// Fall back to existing ExportAll behavior for backward compatibility
|
||||
return r.exportAllFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
|
||||
}
|
||||
|
||||
err := r.wrapWithStageFn(ctx, repo, cloneOptions, fn)
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestExportWorker_IsSupported(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewExportWorker(nil, nil, nil, nil, metrics)
|
||||
r := NewExportWorker(nil, nil, nil, nil, nil, metrics)
|
||||
got := r.IsSupported(context.Background(), tt.job)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
@@ -70,7 +70,7 @@ func TestExportWorker_ProcessNoExportSettings(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), nil, job, nil)
|
||||
require.EqualError(t, err, "missing export settings")
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestExportWorker_ProcessWriteNotAllowed(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, nil)
|
||||
require.EqualError(t, err, "this repository is read only")
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func TestExportWorker_ProcessBranchNotAllowedForLocal(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, nil)
|
||||
require.EqualError(t, err, "this repository does not support the branch workflow")
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestExportWorker_ProcessFailedToCreateClients(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
mockProgress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
@@ -185,7 +185,7 @@ func TestExportWorker_ProcessNotReaderWriter(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "export job submitted targeting repository that is not a ReaderWriter")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
|
||||
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
|
||||
return fn(repo, true)
|
||||
})
|
||||
r := NewExportWorker(mockClients, mockRepoResources, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "create repository resource client: failed to create repository resources client")
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -355,7 +355,7 @@ func TestExportWorker_ProcessStageOptionsWithBranch(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -398,7 +398,7 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "export failed")
|
||||
}
|
||||
@@ -426,7 +426,7 @@ func TestExportWorker_ProcessWrapWithStageFnError(t *testing.T) {
|
||||
mockStageFn := NewMockWrapWithStageFn(t)
|
||||
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed"))
|
||||
|
||||
r := NewExportWorker(nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(nil, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "stage failed")
|
||||
}
|
||||
@@ -452,7 +452,7 @@ func TestExportWorker_ProcessBranchNotAllowedForStageableRepositories(t *testing
|
||||
mockProgress := jobs.NewMockJobProgressRecorder(t)
|
||||
// No progress messages expected in current implementation
|
||||
|
||||
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "this repository does not support the branch workflow")
|
||||
}
|
||||
@@ -504,7 +504,7 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -550,7 +550,7 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
|
||||
return fn(repo, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.EqualError(t, err, "export failed")
|
||||
}
|
||||
@@ -613,7 +613,7 @@ func TestExportWorker_RefURLsSetWithBranch(t *testing.T) {
|
||||
return fn(mockReaderWriter, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -670,7 +670,7 @@ func TestExportWorker_RefURLsNotSetWithoutBranch(t *testing.T) {
|
||||
return fn(mockReaderWriter, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -727,7 +727,7 @@ func TestExportWorker_RefURLsNotSetForNonURLRepository(t *testing.T) {
|
||||
return fn(mockReaderWriter, true)
|
||||
})
|
||||
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
|
||||
err := r.Process(context.Background(), mockRepo, job, mockProgress)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -705,6 +705,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
|
||||
b.clients,
|
||||
b.repositoryResources,
|
||||
export.ExportAll,
|
||||
export.ExportSpecificResources,
|
||||
stageIfPossible,
|
||||
metrics,
|
||||
)
|
||||
|
||||
@@ -167,30 +167,40 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
|
||||
title = name
|
||||
}
|
||||
|
||||
folder := meta.GetFolder()
|
||||
// Get the absolute path of the folder
|
||||
rootFolder := RootFolder(r.repo.Config())
|
||||
fileName := slugify.Slugify(title) + ".json"
|
||||
|
||||
// If no folder is specified in the file, set it to the root to ensure everything is written under it
|
||||
var fid Folder
|
||||
if folder == "" {
|
||||
fid = Folder{ID: rootFolder}
|
||||
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
|
||||
} else {
|
||||
var ok bool
|
||||
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder)
|
||||
// Build the full path: start with options.Path, then add folder path, then filename
|
||||
basePath := options.Path
|
||||
|
||||
// If options.Path is provided, use it directly (it already includes folder structure from export).
|
||||
// Otherwise, resolve folder path from the repository tree.
|
||||
if basePath == "" {
|
||||
folder := meta.GetFolder()
|
||||
// Get the absolute path of the folder
|
||||
rootFolder := RootFolder(r.repo.Config())
|
||||
|
||||
if folder == "" {
|
||||
// If no folder is specified and no path is provided, set it to the root to ensure everything is written under it
|
||||
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
|
||||
} else {
|
||||
var ok bool
|
||||
var fid Folder
|
||||
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
|
||||
if !ok {
|
||||
// Fallback: try without rootFolder (for instance targets where rootFolder is empty)
|
||||
fid, ok = r.folders.Tree().DirPath(folder, "")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("folder %s NOT found in tree", folder)
|
||||
}
|
||||
}
|
||||
if fid.Path != "" {
|
||||
basePath = fid.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileName := slugify.Slugify(title) + ".json"
|
||||
if fid.Path != "" {
|
||||
fileName = safepath.Join(fid.Path, fileName)
|
||||
}
|
||||
|
||||
if options.Path != "" {
|
||||
fileName = safepath.Join(options.Path, fileName)
|
||||
if basePath != "" {
|
||||
fileName = safepath.Join(basePath, fileName)
|
||||
}
|
||||
|
||||
parsed := ParsedResource{
|
||||
|
||||
@@ -145,6 +145,8 @@ func (t *folderTree) AddUnstructured(item *unstructured.Unstructured) error {
|
||||
return fmt.Errorf("extract meta accessor: %w", err)
|
||||
}
|
||||
|
||||
// In Grafana, folder UIDs are stored as metadata.name
|
||||
// The grafana.app/folder annotation contains the folder's metadata.name (which is its Grafana UID)
|
||||
folder := Folder{
|
||||
Title: meta.FindTitle(item.GetName()),
|
||||
ID: item.GetName(),
|
||||
|
||||
@@ -77,10 +77,6 @@ var (
|
||||
"user.sync.user-externalUID-mismatch",
|
||||
errutil.WithPublicMessage("User externalUID mismatch"),
|
||||
)
|
||||
errSCIMAuthModuleMismatch = errutil.Unauthorized(
|
||||
"user.sync.scim-auth-module-mismatch",
|
||||
errutil.WithPublicMessage("User was provisioned via SCIM and must login via SAML"),
|
||||
)
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -312,21 +308,6 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth
|
||||
// just try to fetch the user one more to make the other request work.
|
||||
if errors.Is(err, user.ErrUserAlreadyExists) {
|
||||
usr, _, err = s.getUser(ctx, id)
|
||||
|
||||
// Check if this is a SCIM-provisioned user trying to login via an auth module that is not SAML or GCOM
|
||||
if err == nil && usr != nil && usr.IsProvisioned && id.AuthenticatedBy != login.GrafanaComAuthModule {
|
||||
_, authErr := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{
|
||||
UserId: usr.ID,
|
||||
AuthModule: id.AuthenticatedBy,
|
||||
})
|
||||
if errors.Is(authErr, user.ErrUserNotFound) {
|
||||
s.log.FromContext(ctx).Error("SCIM-provisioned user attempted login via non-SAML auth module",
|
||||
"user_id", usr.ID,
|
||||
"attempted_module", id.AuthenticatedBy,
|
||||
)
|
||||
return errSCIMAuthModuleMismatch.Errorf("user was provisioned via SCIM but attempted login via %s", id.AuthenticatedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1926,100 +1926,3 @@ func TestUserSync_SCIMLoginUsageStatSet(t *testing.T) {
|
||||
finalCount := finalStats["stats.features.scim.has_successful_login.count"].(int)
|
||||
require.Equal(t, int(1), finalCount)
|
||||
}
|
||||
|
||||
func TestUserSync_SyncUserHook_SCIMAuthModuleMismatch(t *testing.T) {
|
||||
userSrv := usertest.NewMockService(t)
|
||||
authInfoSrv := authinfotest.NewMockAuthInfoService(t)
|
||||
|
||||
userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once()
|
||||
|
||||
userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once()
|
||||
|
||||
userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{
|
||||
ID: 1,
|
||||
Email: "test@test.com",
|
||||
IsProvisioned: true,
|
||||
}, nil).Once()
|
||||
|
||||
authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool {
|
||||
return q.AuthModule == "oauth_azuread"
|
||||
})).Return(nil, user.ErrUserNotFound).Once()
|
||||
|
||||
s := ProvideUserSync(
|
||||
userSrv,
|
||||
authinfoimpl.ProvideOSSUserProtectionService(),
|
||||
authInfoSrv,
|
||||
"atest.FakeQuotaService{},
|
||||
tracing.NewNoopTracerService(),
|
||||
featuremgmt.WithFeatures(),
|
||||
setting.NewCfg(),
|
||||
nil,
|
||||
)
|
||||
|
||||
email := "test@test.com"
|
||||
|
||||
err := s.SyncUserHook(context.Background(), &authn.Identity{
|
||||
AuthenticatedBy: "oauth_azuread",
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
LookUpParams: login.UserLookupParams{
|
||||
Email: &email,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSCIMAuthModuleMismatch)
|
||||
assert.Contains(t, err.Error(), "SCIM")
|
||||
assert.Contains(t, err.Error(), "oauth_azuread")
|
||||
}
|
||||
|
||||
func TestUserSync_SyncUserHook_SCIMUserAllowsGCOMLogin(t *testing.T) {
|
||||
userSrv := usertest.NewMockService(t)
|
||||
authInfoSrv := authinfotest.NewMockAuthInfoService(t)
|
||||
|
||||
authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool {
|
||||
return q.AuthModule == login.GrafanaComAuthModule && q.AuthId == "gcom-user-123"
|
||||
})).Return(nil, user.ErrUserNotFound).Once()
|
||||
|
||||
userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once()
|
||||
userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once()
|
||||
|
||||
authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool {
|
||||
return q.AuthModule == login.GrafanaComAuthModule && q.AuthId == "gcom-user-123"
|
||||
})).Return(nil, user.ErrUserNotFound).Once()
|
||||
|
||||
userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{
|
||||
ID: 1,
|
||||
Email: "test@test.com",
|
||||
IsProvisioned: true,
|
||||
}, nil).Once()
|
||||
|
||||
s := ProvideUserSync(
|
||||
userSrv,
|
||||
authinfoimpl.ProvideOSSUserProtectionService(),
|
||||
authInfoSrv,
|
||||
"atest.FakeQuotaService{},
|
||||
tracing.NewNoopTracerService(),
|
||||
featuremgmt.WithFeatures(),
|
||||
setting.NewCfg(),
|
||||
nil,
|
||||
)
|
||||
|
||||
email := "test@test.com"
|
||||
|
||||
err := s.SyncUserHook(context.Background(), &authn.Identity{
|
||||
AuthenticatedBy: login.GrafanaComAuthModule,
|
||||
AuthID: "gcom-user-123",
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
LookUpParams: login.UserLookupParams{
|
||||
Email: &email,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -304,15 +304,8 @@ type DeleteDashboardCommand struct {
|
||||
RemovePermissions bool
|
||||
}
|
||||
|
||||
type ProvisioningConfig struct {
|
||||
Name string
|
||||
OrgID int64
|
||||
Folder string
|
||||
AllowUIUpdates bool
|
||||
}
|
||||
|
||||
type DeleteOrphanedProvisionedDashboardsCommand struct {
|
||||
Config []ProvisioningConfig
|
||||
ReaderNames []string
|
||||
}
|
||||
|
||||
type DashboardProvisioningSearchResults struct {
|
||||
@@ -412,8 +405,6 @@ type DashboardSearchProjection struct {
|
||||
FolderTitle string
|
||||
SortMeta int64
|
||||
Tags []string
|
||||
ManagedBy utils.ManagerKind
|
||||
ManagerId string
|
||||
Deleted *time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -877,32 +877,24 @@ func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *d
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error {
|
||||
// cleanup duplicate provisioned dashboards first (this will have the same name and external_id)
|
||||
// note: only works in modes 1-3
|
||||
if err := dr.DeleteDuplicateProvisionedDashboards(ctx); err != nil {
|
||||
dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err)
|
||||
}
|
||||
|
||||
// check each org for orphaned provisioned dashboards
|
||||
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orgIDs := make([]int64, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
orgIDs = append(orgIDs, org.ID)
|
||||
}
|
||||
|
||||
if err := dr.DeleteDuplicateProvisionedDashboards(ctx, orgIDs, cmd.Config); err != nil {
|
||||
dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err)
|
||||
}
|
||||
|
||||
currentNames := make([]string, 0, len(cmd.Config))
|
||||
for _, cfg := range cmd.Config {
|
||||
currentNames = append(currentNames, cfg.Name)
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
||||
// find all dashboards in the org that have a file repo set that is not in the given readers list
|
||||
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck
|
||||
ManagerIdentityNotIn: currentNames,
|
||||
ManagerIdentityNotIn: cmd.ReaderNames,
|
||||
OrgId: org.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -929,129 +921,7 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchExistingProvisionedData fetches provisioned data for the purposes of
|
||||
// duplication cleanup. Returns the set of folder UIDs for folders with the
|
||||
// given title, and the set of resources contained in those folders.
|
||||
func (dr *DashboardServiceImpl) searchExistingProvisionedData(
|
||||
ctx context.Context, orgID int64, folderTitle string,
|
||||
) ([]string, []dashboards.DashboardSearchProjection, error) {
|
||||
ctx, user := identity.WithServiceIdentity(ctx, orgID)
|
||||
cmd := folder.SearchFoldersQuery{
|
||||
OrgID: orgID,
|
||||
SignedInUser: user,
|
||||
Title: folderTitle,
|
||||
TitleExactMatch: true,
|
||||
}
|
||||
|
||||
searchResults, err := dr.folderService.SearchFolders(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("checking if provisioning reset is required: %w", err)
|
||||
}
|
||||
|
||||
var matchingFolders []string //nolint:prealloc
|
||||
for _, result := range searchResults {
|
||||
f, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{
|
||||
OrgID: orgID,
|
||||
UID: &result.UID,
|
||||
SignedInUser: user,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// We are only interested in folders at the top-level of the folder hierarchy.
|
||||
// Cleanup is not performed for provisioned folders that were moved to
|
||||
// a different location.
|
||||
if f.ParentUID != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
matchingFolders = append(matchingFolders, f.UID)
|
||||
}
|
||||
|
||||
if len(matchingFolders) == 0 {
|
||||
// If there are no folders with the same title as the provisioned folder we
|
||||
// are looking for, there is nothing to be cleaned up.
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
resources, err := dr.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: orgID,
|
||||
SignedInUser: user,
|
||||
FolderUIDs: matchingFolders,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return matchingFolders, resources, nil
|
||||
}
|
||||
|
||||
// maybeResetProvisioning will check for duplicated provisioned dashboards in the database. These duplications
|
||||
// happen when multiple provisioned dashboards of the same title are found, or multiple provisioned
|
||||
// folders are found. In this case, provisioned resources are deleted, allowing the provisioning
|
||||
// process to start from scratch after this function returns.
|
||||
func (dr *DashboardServiceImpl) maybeResetProvisioning(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) {
|
||||
if skipReason := canBeAutomaticallyCleanedUp(configs); skipReason != "" {
|
||||
dr.log.Info("not eligible for automated cleanup", "reason", skipReason)
|
||||
return
|
||||
}
|
||||
|
||||
folderTitle := configs[0].Folder
|
||||
provisionedNames := map[string]bool{}
|
||||
for _, c := range configs {
|
||||
provisionedNames[c.Name] = true
|
||||
}
|
||||
|
||||
for _, orgID := range orgs {
|
||||
ctx, user := identity.WithServiceIdentity(ctx, orgID)
|
||||
provFolders, resources, err := dr.searchExistingProvisionedData(ctx, orgID, folderTitle)
|
||||
if err != nil {
|
||||
dr.log.Error("failed to search for provisioned data for cleanup", "org", orgID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
steps, err := cleanupSteps(provFolders, resources, provisionedNames)
|
||||
if err != nil {
|
||||
dr.log.Warn("not possible to perform automated duplicate cleanup", "org", orgID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
var err error
|
||||
|
||||
switch step.Type {
|
||||
case searchstore.TypeDashboard:
|
||||
err = dr.deleteDashboard(ctx, 0, step.UID, orgID, false)
|
||||
case searchstore.TypeFolder:
|
||||
err = dr.folderService.Delete(ctx, &folder.DeleteFolderCommand{
|
||||
OrgID: orgID,
|
||||
SignedInUser: user,
|
||||
UID: step.UID,
|
||||
})
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
dr.log.Info("deleted duplicated provisioned resource",
|
||||
"type", step.Type, "uid", step.UID,
|
||||
)
|
||||
} else {
|
||||
dr.log.Error("failed to delete duplicated provisioned resource",
|
||||
"type", step.Type, "uid", step.UID, "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) error {
|
||||
// Start from scratch if duplications that cannot be fixed by the logic
|
||||
// below are found in the database.
|
||||
dr.maybeResetProvisioning(ctx, orgs, configs)
|
||||
|
||||
// cleanup duplicate provisioned dashboards (i.e., with the same name and external_id).
|
||||
// Note: only works in modes 1-3. This logic can be removed once mode5 is
|
||||
// enabled everywhere.
|
||||
func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context) error {
|
||||
duplicates, err := dr.dashboardStore.GetDuplicateProvisionedDashboards(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1641,8 +1511,6 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
|
||||
FolderTitle: folderTitle,
|
||||
FolderID: folderID,
|
||||
FolderSlug: slugify.Slugify(folderTitle),
|
||||
ManagedBy: hit.ManagedBy.Kind,
|
||||
ManagerId: hit.ManagedBy.ID,
|
||||
Tags: hit.Tags,
|
||||
}
|
||||
|
||||
|
||||
@@ -779,7 +779,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
}, nil).Twice()
|
||||
|
||||
err := service.DeleteOrphanedProvisionedDashboards(context.Background(), &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
|
||||
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
|
||||
ReaderNames: []string{"test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
@@ -874,7 +874,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
}, nil).Once()
|
||||
|
||||
err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
|
||||
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
|
||||
ReaderNames: []string{"test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
@@ -906,7 +906,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
|
||||
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
|
||||
ReaderNames: []string{"test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
k8sCliMock.AssertExpectations(t)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
)
|
||||
|
||||
// canBeAutomaticallyCleanedUp determines whether this instance can be automatically cleaned up
|
||||
// if duplicated provisioned resources are found. To ensure the process does not delete
|
||||
// resources it shouldn't, automatic cleanups only happen if all provisioned dashboards
|
||||
// are stored in the same folder (by title), and no dashboards allow UI updates.
|
||||
func canBeAutomaticallyCleanedUp(configs []dashboards.ProvisioningConfig) string {
|
||||
if len(configs) == 0 {
|
||||
return "no provisioned dashboards"
|
||||
}
|
||||
|
||||
folderTitle := configs[0].Folder
|
||||
if len(folderTitle) == 0 {
|
||||
return fmt.Sprintf("dashboard has no folder: %s", configs[0].Name)
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
if cfg.AllowUIUpdates {
|
||||
return "contains dashboards with allowUiUpdates"
|
||||
}
|
||||
|
||||
if cfg.Folder != folderTitle {
|
||||
return "dashboards provisioned across multiple folders"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type deleteProvisionedResource struct {
|
||||
Type string
|
||||
UID string
|
||||
}
|
||||
|
||||
// cleanupSteps computes the sequence of steps to be performed in order to cleanup the
|
||||
// provisioning resources and allow the process to start from scratch when duplication
|
||||
// is detected. The sequence of steps will dictate the order in which dashboards and folders
|
||||
// are to be deleted.
|
||||
func cleanupSteps(provFolders []string, resources []dashboards.DashboardSearchProjection, configDashboards map[string]bool) ([]deleteProvisionedResource, error) {
|
||||
var hasDuplicatedProvisionedDashboard bool
|
||||
var hasUserCreatedResource bool
|
||||
var uniqueNames = map[string]struct{}{}
|
||||
var deleteProvisionedDashboards []deleteProvisionedResource //nolint:prealloc
|
||||
|
||||
for _, r := range resources {
|
||||
// nolint:staticcheck
|
||||
if r.IsFolder || r.ManagedBy != utils.ManagerKindClassicFP {
|
||||
hasUserCreatedResource = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Only delete dashboards if they are included in the provisioning configuration
|
||||
// for this instance.
|
||||
if !configDashboards[r.ManagerId] {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := uniqueNames[r.ManagerId]; exists {
|
||||
hasDuplicatedProvisionedDashboard = true
|
||||
}
|
||||
|
||||
uniqueNames[r.ManagerId] = struct{}{}
|
||||
deleteProvisionedDashboards = append(deleteProvisionedDashboards, deleteProvisionedResource{
|
||||
Type: searchstore.TypeDashboard,
|
||||
UID: r.UID,
|
||||
})
|
||||
}
|
||||
|
||||
if len(provFolders) == 0 {
|
||||
// When there are no provisioned folders, there is nothing to do.
|
||||
return nil, nil
|
||||
} else if len(provFolders) == 1 {
|
||||
// If only one folder was found, keep it and delete the provisioned dashboards if
|
||||
// duplication was found.
|
||||
if hasDuplicatedProvisionedDashboard {
|
||||
return deleteProvisionedDashboards, nil
|
||||
}
|
||||
} else {
|
||||
// If multiple folders were found *and* a user-created resource exists in
|
||||
// one of them, bail, as we wouldn't be able to delete one of the duplicated folders.
|
||||
if hasUserCreatedResource {
|
||||
return nil, errors.New("multiple provisioning folders exist with at least one user-created resource")
|
||||
}
|
||||
|
||||
// Delete provisioned dashboards first, and then the folders.
|
||||
steps := deleteProvisionedDashboards
|
||||
for _, uid := range provFolders {
|
||||
steps = append(steps, deleteProvisionedResource{
|
||||
Type: searchstore.TypeFolder,
|
||||
UID: uid,
|
||||
})
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_canBeAutomaticallyCleanedUp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
configs []dashboards.ProvisioningConfig
|
||||
expectedSkip string
|
||||
}{
|
||||
{
|
||||
name: "no dashboards defined in the configuration",
|
||||
configs: []dashboards.ProvisioningConfig{},
|
||||
expectedSkip: "no provisioned dashboards",
|
||||
},
|
||||
{
|
||||
name: "first defined dashboard has no folder defined",
|
||||
configs: []dashboards.ProvisioningConfig{
|
||||
{Name: "1", Folder: ""},
|
||||
{Folder: "f1"},
|
||||
},
|
||||
expectedSkip: "dashboard has no folder: 1",
|
||||
},
|
||||
{
|
||||
name: "one of the provisioned dashboards has no folder defined",
|
||||
configs: []dashboards.ProvisioningConfig{
|
||||
{Name: "1", Folder: "f1"},
|
||||
{Name: "2", Folder: "f1"},
|
||||
{Name: "3", Folder: ""},
|
||||
{Name: "4", Folder: "f1"},
|
||||
},
|
||||
expectedSkip: "dashboards provisioned across multiple folders",
|
||||
},
|
||||
{
|
||||
name: "one of the provisioned dashboards allows UI updates",
|
||||
configs: []dashboards.ProvisioningConfig{
|
||||
{Name: "1", Folder: "f1"},
|
||||
{Name: "2", Folder: "f1", AllowUIUpdates: true},
|
||||
{Name: "3", Folder: "f1"},
|
||||
{Name: "4", Folder: "f1"},
|
||||
},
|
||||
expectedSkip: "contains dashboards with allowUiUpdates",
|
||||
},
|
||||
{
|
||||
name: "one of the provisioned dashboards is in a different folder",
|
||||
configs: []dashboards.ProvisioningConfig{
|
||||
{Name: "1", Folder: "f1"},
|
||||
{Name: "2", Folder: "f1"},
|
||||
{Name: "3", Folder: "f1"},
|
||||
{Name: "4", Folder: "different"},
|
||||
},
|
||||
expectedSkip: "dashboards provisioned across multiple folders",
|
||||
},
|
||||
{
|
||||
name: "can be skipped when all conditions are met",
|
||||
configs: []dashboards.ProvisioningConfig{
|
||||
{Name: "1", Folder: "f1"},
|
||||
{Name: "2", Folder: "f1"},
|
||||
{Name: "3", Folder: "f1"},
|
||||
{Name: "4", Folder: "f1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.expectedSkip, canBeAutomaticallyCleanedUp(tc.configs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_cleanupSteps(t *testing.T) {
|
||||
isDashboard, isFolder := false, true
|
||||
|
||||
fromUser := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection {
|
||||
return dashboards.DashboardSearchProjection{
|
||||
UID: uid,
|
||||
ManagerId: name,
|
||||
IsFolder: isFolder,
|
||||
}
|
||||
}
|
||||
|
||||
provisioned := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection {
|
||||
dashboard := fromUser(uid, name, isFolder)
|
||||
dashboard.ManagedBy = utils.ManagerKindClassicFP //nolint:staticcheck
|
||||
return dashboard
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
provisionedFolders []string
|
||||
provisionedResources []dashboards.DashboardSearchProjection
|
||||
configDashboards []string
|
||||
expectedSteps []deleteProvisionedResource
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "no provisioned folders, nothing to do",
|
||||
provisionedFolders: []string{},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple folders, a user-created dashboard in one of them",
|
||||
provisionedFolders: []string{"folder1", "folder2"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
fromUser("d3", "User1", isDashboard),
|
||||
provisioned("d4", "Provisioned3", isDashboard),
|
||||
},
|
||||
expectedErr: "multiple provisioning folders exist with at least one user-created resource",
|
||||
},
|
||||
{
|
||||
name: "multiple folders, a user-created folder in one of them",
|
||||
provisionedFolders: []string{"folder1", "folder2"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned3", isDashboard),
|
||||
fromUser("f1", "UserFolder1", isFolder),
|
||||
},
|
||||
expectedErr: "multiple provisioning folders exist with at least one user-created resource",
|
||||
},
|
||||
{
|
||||
name: "single folder, some dashboards duplicated",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
// Provisioned1 is duplicated.
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned1", isDashboard),
|
||||
provisioned("d4", "Provisioned3", isDashboard),
|
||||
},
|
||||
expectedSteps: []deleteProvisionedResource{
|
||||
{Type: searchstore.TypeDashboard, UID: "d1"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d2"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d3"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d4"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single folder, duplicated dashboards, user-created dashboards are ignored",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
// Provisioned1 is duplicated.
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
fromUser("d3", "User1", isDashboard),
|
||||
provisioned("d4", "Provisioned3", isDashboard),
|
||||
provisioned("d5", "Provisioned1", isDashboard),
|
||||
},
|
||||
// User dashboard (d3) is not deleted.
|
||||
expectedSteps: []deleteProvisionedResource{
|
||||
{Type: searchstore.TypeDashboard, UID: "d1"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d2"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d4"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single folder, duplicated dashboards, user-created folders are ignored",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
// Provisioned1 is duplicated.
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned3", isDashboard),
|
||||
provisioned("d4", "Provisioned1", isDashboard),
|
||||
fromUser("f1", "UserFolder1", isFolder),
|
||||
},
|
||||
// User folder (f1) is not deleted.
|
||||
expectedSteps: []deleteProvisionedResource{
|
||||
{Type: searchstore.TypeDashboard, UID: "d1"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d2"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d3"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d4"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple folders, only provisioned dashboards",
|
||||
provisionedFolders: []string{"folder1", "folder2"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned3", isDashboard),
|
||||
provisioned("d4", "Provisioned4", isDashboard),
|
||||
},
|
||||
// Delete all dashboards, then all folders.
|
||||
expectedSteps: []deleteProvisionedResource{
|
||||
{Type: searchstore.TypeDashboard, UID: "d1"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d2"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d3"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d4"},
|
||||
{Type: searchstore.TypeFolder, UID: "folder1"},
|
||||
{Type: searchstore.TypeFolder, UID: "folder2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single folder, only deletes dashboards defined in the config file",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned1", isDashboard),
|
||||
provisioned("d4", "Provisioned4", isDashboard),
|
||||
provisioned("d5", "Provisioned4", isDashboard),
|
||||
},
|
||||
// Delete duplicated dashboards, but keep Provisioned4, since it's not in the config file.
|
||||
expectedSteps: []deleteProvisionedResource{
|
||||
{Type: searchstore.TypeDashboard, UID: "d1"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d2"},
|
||||
{Type: searchstore.TypeDashboard, UID: "d3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single folder, no duplicated dashboards",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
provisioned("d3", "Provisioned3", isDashboard),
|
||||
provisioned("d4", "Provisioned4", isDashboard),
|
||||
},
|
||||
expectedSteps: nil, // no duplicates, nothing to do
|
||||
},
|
||||
{
|
||||
name: "single folder, no duplicated dashboards, multiple user-created resources",
|
||||
provisionedFolders: []string{"folder1"},
|
||||
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
|
||||
provisionedResources: []dashboards.DashboardSearchProjection{
|
||||
provisioned("d1", "Provisioned1", isDashboard),
|
||||
provisioned("d2", "Provisioned2", isDashboard),
|
||||
fromUser("f1", "UserFolder1", isFolder),
|
||||
provisioned("d3", "Provisioned3", isDashboard),
|
||||
fromUser("d4", "User1", isDashboard),
|
||||
provisioned("d5", "Provisioned4", isDashboard),
|
||||
fromUser("d6", "User2", isDashboard),
|
||||
fromUser("f2", "UserFolder2", isFolder),
|
||||
},
|
||||
expectedSteps: nil, // no duplicates in the provisioned set, nothing to do
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provisionedSet := make(map[string]bool)
|
||||
for _, name := range tc.configDashboards {
|
||||
provisionedSet[name] = true
|
||||
}
|
||||
|
||||
steps, err := cleanupSteps(tc.provisionedFolders, tc.provisionedResources, provisionedSet)
|
||||
if tc.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedSteps, steps)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tc.expectedErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -202,11 +202,6 @@ func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.S
|
||||
if query.Title != "" {
|
||||
// allow wildcard search
|
||||
request.Query = "*" + strings.ToLower(query.Title) + "*"
|
||||
// or perform exact match if requested
|
||||
if query.TitleExactMatch {
|
||||
request.Query = query.Title
|
||||
}
|
||||
|
||||
// if using query, you need to specify the fields you want
|
||||
request.Fields = dashboardsearch.IncludeFields
|
||||
}
|
||||
|
||||
@@ -224,13 +224,12 @@ type GetFoldersQuery struct {
|
||||
}
|
||||
|
||||
type SearchFoldersQuery struct {
|
||||
OrgID int64
|
||||
UIDs []string
|
||||
IDs []int64
|
||||
Title string
|
||||
TitleExactMatch bool
|
||||
Limit int64
|
||||
SignedInUser identity.Requester `json:"-"`
|
||||
OrgID int64
|
||||
UIDs []string
|
||||
IDs []int64
|
||||
Title string
|
||||
Limit int64
|
||||
SignedInUser identity.Requester `json:"-"`
|
||||
}
|
||||
|
||||
// GetParentsQuery captures the information required by the folder service to
|
||||
|
||||
@@ -153,20 +153,13 @@ func (provider *Provisioner) Provision(ctx context.Context) error {
|
||||
|
||||
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
|
||||
func (provider *Provisioner) CleanUpOrphanedDashboards(ctx context.Context) {
|
||||
configs := make([]dashboards.ProvisioningConfig, len(provider.fileReaders))
|
||||
currentReaders := make([]string, len(provider.fileReaders))
|
||||
|
||||
for index, reader := range provider.fileReaders {
|
||||
configs[index] = dashboards.ProvisioningConfig{
|
||||
Name: reader.Cfg.Name,
|
||||
OrgID: reader.Cfg.OrgID,
|
||||
Folder: reader.Cfg.Folder,
|
||||
AllowUIUpdates: reader.Cfg.AllowUIUpdates,
|
||||
}
|
||||
currentReaders[index] = reader.Cfg.Name
|
||||
}
|
||||
|
||||
if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(
|
||||
ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{Config: configs},
|
||||
); err != nil {
|
||||
if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil {
|
||||
provider.log.Warn("Failed to delete orphaned provisioned dashboards", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3288,6 +3288,18 @@
|
||||
"path": {
|
||||
"description": "FIXME: we should validate this in admission hooks Prefix in target file system",
|
||||
"type": "string"
|
||||
},
|
||||
"resources": {
|
||||
"description": "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"default": {},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceRef"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationProvisioning_ExportSpecificResources(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create unmanaged dashboards directly in Grafana
|
||||
dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
|
||||
dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create first dashboard")
|
||||
dashboard1Name := dashboard1Obj.GetName()
|
||||
|
||||
dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml")
|
||||
dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create second dashboard")
|
||||
dashboard2Name := dashboard2Obj.GetName()
|
||||
|
||||
// Verify dashboards are unmanaged
|
||||
dash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
manager1, found1 := dash1.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
||||
require.True(t, !found1 || manager1 == "", "dashboard1 should be unmanaged")
|
||||
|
||||
dash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
manager2, found2 := dash2.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
||||
require.True(t, !found2 || manager2 == "", "dashboard2 should be unmanaged")
|
||||
|
||||
// Create repository with folder sync target (required for specific resource export)
|
||||
const repo = "export-resources-test-repo"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0, // No dashboards expected after sync (we'll export manually)
|
||||
ExpectedFolders: 0,
|
||||
SkipResourceAssertions: true, // Skip assertions since we created dashboards before repo
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Export specific dashboards using Resources field
|
||||
spec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Path: "",
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: dashboard1Name,
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
{
|
||||
Name: dashboard2Name,
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
|
||||
|
||||
// Verify both dashboards were exported
|
||||
dashboard1File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
|
||||
dashboard2File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v2beta1.json")
|
||||
|
||||
// Check dashboard1
|
||||
body1, err := os.ReadFile(dashboard1File) //nolint:gosec
|
||||
require.NoError(t, err, "exported file should exist for dashboard1")
|
||||
obj1 := map[string]any{}
|
||||
err = json.Unmarshal(body1, &obj1)
|
||||
require.NoError(t, err, "exported file should be valid JSON")
|
||||
val, _, err := unstructured.NestedString(obj1, "metadata", "name")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-v1", val)
|
||||
|
||||
// Check dashboard2
|
||||
body2, err := os.ReadFile(dashboard2File) //nolint:gosec
|
||||
require.NoError(t, err, "exported file should exist for dashboard2")
|
||||
obj2 := map[string]any{}
|
||||
err = json.Unmarshal(body2, &obj2)
|
||||
require.NoError(t, err, "exported file should be valid JSON")
|
||||
val, _, err = unstructured.NestedString(obj2, "metadata", "name")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-v2beta1", val)
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ExportSpecificResourcesWithPath(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create unmanaged dashboard
|
||||
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
|
||||
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create dashboard")
|
||||
dashboardName := dashboardObj.GetName()
|
||||
|
||||
// Create repository with folder sync target (required for specific resource export)
|
||||
const repo = "export-resources-path-test-repo"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
SkipResourceAssertions: true, // Skip assertions since we created dashboard before repo
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Export with custom path
|
||||
spec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Path: "custom/path",
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: dashboardName,
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
|
||||
|
||||
// Verify dashboard was exported to custom path
|
||||
expectedFile := filepath.Join(helper.ProvisioningPath, "custom", "path", "test-dashboard-created-at-v1.json")
|
||||
body, err := os.ReadFile(expectedFile) //nolint:gosec
|
||||
require.NoError(t, err, "exported file should exist at custom path")
|
||||
obj := map[string]any{}
|
||||
err = json.Unmarshal(body, &obj)
|
||||
require.NoError(t, err, "exported file should be valid JSON")
|
||||
val, _, err := unstructured.NestedString(obj, "metadata", "name")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-v1", val)
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsFolders(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a folder
|
||||
folder := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "folder.grafana.app/v1beta1",
|
||||
"kind": "Folder",
|
||||
"metadata": map[string]any{
|
||||
"name": "test-folder",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Test Folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create folder")
|
||||
folderName := folderObj.GetName()
|
||||
|
||||
// Create repository with folder sync target (required for specific resource export)
|
||||
const repo = "export-reject-folders-test-repo"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
SkipResourceAssertions: true, // Skip assertions since we created folder before repo
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Try to export folder (should fail validation)
|
||||
spec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: folderName,
|
||||
Kind: "Folder",
|
||||
Group: "folder.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// This should fail with validation error
|
||||
body := asJSON(spec)
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("jobs").
|
||||
Body(body).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
err = result.Error()
|
||||
require.Error(t, err, "should fail validation when trying to export folder")
|
||||
require.Contains(t, err.Error(), "folders are not supported", "error should mention folders are not supported")
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsManagedResources(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a managed dashboard via repository sync (use folder target to allow second repo)
|
||||
testRepo := TestRepo{
|
||||
Name: "managed-dashboard-repo",
|
||||
Target: "folder",
|
||||
Copies: map[string]string{
|
||||
"exportunifiedtorepository/dashboard-test-v1.yaml": "dashboard.json",
|
||||
},
|
||||
ExpectedDashboards: 1,
|
||||
ExpectedFolders: 1, // Folder target creates a folder with the repo name
|
||||
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Get the managed dashboard
|
||||
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dashboards.Items, 1, "should have one managed dashboard")
|
||||
managedDashboard := dashboards.Items[0]
|
||||
managedDashboardName := managedDashboard.GetName()
|
||||
|
||||
// Verify it's managed
|
||||
manager, found := managedDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
||||
require.True(t, found && manager != "", "dashboard should be managed")
|
||||
|
||||
// Create another repository for export (must be folder target since instance can only exist alone)
|
||||
const exportRepo = "export-managed-reject-test-repo"
|
||||
exportTestRepo := TestRepo{
|
||||
Name: exportRepo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
|
||||
}
|
||||
helper.CreateRepo(t, exportTestRepo)
|
||||
|
||||
// Try to export managed dashboard (should fail)
|
||||
spec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: managedDashboardName,
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// This should fail because the resource is managed
|
||||
body := asJSON(spec)
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(exportRepo).
|
||||
SubResource("jobs").
|
||||
Body(body).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
|
||||
// Wait for job to complete and check it failed
|
||||
obj, err := result.Get()
|
||||
require.NoError(t, err, "job should be created")
|
||||
unstruct, ok := obj.(*unstructured.Unstructured)
|
||||
require.True(t, ok, "should get unstructured object")
|
||||
|
||||
// Wait for job to complete
|
||||
job := helper.AwaitJob(t, ctx, unstruct)
|
||||
lastState := mustNestedString(job.Object, "status", "state")
|
||||
lastErrors := mustNestedStringSlice(job.Object, "status", "errors")
|
||||
|
||||
// Job should fail with error about managed resource
|
||||
require.Equal(t, string(provisioning.JobStateError), lastState, "job should fail")
|
||||
require.NotEmpty(t, lastErrors, "job should have errors")
|
||||
require.Contains(t, lastErrors[0], "managed", "error should mention managed resource")
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ExportSpecificResourcesWithFolderStructure(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an unmanaged folder
|
||||
folder := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "folder.grafana.app/v1beta1",
|
||||
"kind": "Folder",
|
||||
"metadata": map[string]any{
|
||||
"name": "test-export-folder",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Test Export Folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create folder")
|
||||
folderUID := folderObj.GetUID()
|
||||
|
||||
// Verify folder is unmanaged
|
||||
manager, found := folderObj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
||||
require.True(t, !found || manager == "", "folder should be unmanaged")
|
||||
|
||||
// Create unmanaged dashboard in the folder
|
||||
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
|
||||
// Set folder UID in dashboard spec
|
||||
err = unstructured.SetNestedField(dashboard.Object, string(folderUID), "spec", "folder")
|
||||
require.NoError(t, err, "should be able to set folder UID")
|
||||
|
||||
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
|
||||
require.NoError(t, err, "should be able to create dashboard in folder")
|
||||
dashboardName := dashboardObj.GetName()
|
||||
|
||||
// Create repository with folder sync target (required for specific resource export)
|
||||
const repo = "export-folder-structure-test-repo"
|
||||
testRepo := TestRepo{
|
||||
Name: repo,
|
||||
Target: "folder",
|
||||
Copies: map[string]string{},
|
||||
ExpectedDashboards: 0,
|
||||
ExpectedFolders: 0,
|
||||
SkipResourceAssertions: true, // Skip assertions since we created folder and dashboard before repo
|
||||
}
|
||||
helper.CreateRepo(t, testRepo)
|
||||
|
||||
// Export dashboard (should preserve folder structure)
|
||||
spec := provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
Push: &provisioning.ExportJobOptions{
|
||||
Path: "",
|
||||
Resources: []provisioning.ResourceRef{
|
||||
{
|
||||
Name: dashboardName,
|
||||
Kind: "Dashboard",
|
||||
Group: "dashboard.grafana.app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
|
||||
|
||||
// For folder sync targets with specific resource export, the folder structure
|
||||
// from unmanaged folders should be preserved in the export path
|
||||
// Expected: <provisioning_path>/<folder_name>/<dashboard>.json
|
||||
expectedFile := filepath.Join(helper.ProvisioningPath, "Test Export Folder", "test-dashboard-created-at-v1.json")
|
||||
body, err := os.ReadFile(expectedFile) //nolint:gosec
|
||||
if err != nil {
|
||||
// Fallback: if folder structure not preserved, file might be at root
|
||||
expectedFile = filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
|
||||
body, err = os.ReadFile(expectedFile) //nolint:gosec
|
||||
require.NoError(t, err, "exported file should exist (either with folder structure or at root)")
|
||||
t.Logf("Note: Dashboard exported to root instead of preserving folder structure")
|
||||
}
|
||||
|
||||
obj := map[string]any{}
|
||||
err = json.Unmarshal(body, &obj)
|
||||
require.NoError(t, err, "exported file should be valid JSON")
|
||||
val, _, err := unstructured.NestedString(obj, "metadata", "name")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-v1", val)
|
||||
}
|
||||
@@ -262,57 +262,4 @@ describe('TabsLayoutManager', () => {
|
||||
expect(manager.getVizPanels().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromLayout', () => {
|
||||
it('should convert rows with titles to tabs', () => {
|
||||
const rowsLayout = new RowsLayoutManager({
|
||||
rows: [new RowItem({ title: 'Row 1' }), new RowItem({ title: 'Row 2' })],
|
||||
});
|
||||
|
||||
const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout);
|
||||
|
||||
expect(tabsManager.state.tabs).toHaveLength(2);
|
||||
expect(tabsManager.state.tabs[0].state.title).toBe('Row 1');
|
||||
expect(tabsManager.state.tabs[1].state.title).toBe('Row 2');
|
||||
});
|
||||
|
||||
it('should use default title when row has empty title', () => {
|
||||
const rowsLayout = new RowsLayoutManager({
|
||||
rows: [new RowItem({ title: '' })],
|
||||
});
|
||||
|
||||
const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout);
|
||||
|
||||
expect(tabsManager.state.tabs).toHaveLength(1);
|
||||
expect(tabsManager.state.tabs[0].state.title).toBe('New tab');
|
||||
});
|
||||
|
||||
it('should generate unique titles for multiple rows with empty titles', () => {
|
||||
const rowsLayout = new RowsLayoutManager({
|
||||
rows: [new RowItem({ title: '' }), new RowItem({ title: '' }), new RowItem({ title: '' })],
|
||||
});
|
||||
|
||||
const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout);
|
||||
|
||||
expect(tabsManager.state.tabs).toHaveLength(3);
|
||||
expect(tabsManager.state.tabs[0].state.title).toBe('New tab');
|
||||
expect(tabsManager.state.tabs[1].state.title).toBe('New tab 1');
|
||||
expect(tabsManager.state.tabs[2].state.title).toBe('New tab 2');
|
||||
});
|
||||
|
||||
it('should generate unique titles when mixing empty and existing titles', () => {
|
||||
const rowsLayout = new RowsLayoutManager({
|
||||
rows: [
|
||||
new RowItem({ title: 'New row' }), // existing title that matches default
|
||||
new RowItem({ title: '' }), // empty, should get unique title
|
||||
],
|
||||
});
|
||||
|
||||
const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout);
|
||||
|
||||
expect(tabsManager.state.tabs).toHaveLength(2);
|
||||
expect(tabsManager.state.tabs[0].state.title).toBe('New row');
|
||||
expect(tabsManager.state.tabs[1].state.title).toBe('New tab');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,10 +410,6 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
let tabs: TabItem[] = [];
|
||||
|
||||
if (layout instanceof RowsLayoutManager) {
|
||||
const existingNames = new Set(
|
||||
layout.state.rows.map((row) => row.state.title).filter((title): title is string => !!title)
|
||||
);
|
||||
|
||||
for (const row of layout.state.rows) {
|
||||
if (row.state.repeatSourceKey) {
|
||||
continue;
|
||||
@@ -424,14 +420,10 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
// We need to clear the target since we don't want to point the original row anymore (if it was set)
|
||||
conditionalRendering?.setTarget(undefined);
|
||||
|
||||
const newTitle =
|
||||
row.state.title || generateUniqueTitle(t('dashboard.tabs-layout.tab.new', 'New tab'), existingNames);
|
||||
existingNames.add(newTitle);
|
||||
|
||||
tabs.push(
|
||||
new TabItem({
|
||||
layout: row.state.layout.clone(),
|
||||
title: newTitle,
|
||||
title: row.state.title,
|
||||
conditionalRendering,
|
||||
repeatByVariable: row.state.repeatByVariable,
|
||||
})
|
||||
|
||||
@@ -256,11 +256,7 @@ export const InfiniteScroll = ({
|
||||
if (props.visibleStartIndex === 0) {
|
||||
noScrollRef.current = scrollElement.scrollHeight <= scrollElement.clientHeight;
|
||||
}
|
||||
if (noScrollRef.current) {
|
||||
setInfiniteLoaderState('idle');
|
||||
return;
|
||||
}
|
||||
if (infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') {
|
||||
if (noScrollRef.current || infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') {
|
||||
return;
|
||||
}
|
||||
const lastLogIndex = logs.length - 1;
|
||||
@@ -271,7 +267,7 @@ export const InfiniteScroll = ({
|
||||
setInfiniteLoaderState('idle');
|
||||
}
|
||||
},
|
||||
[infiniteLoaderState, logs, scrollElement]
|
||||
[infiniteLoaderState, logs.length, scrollElement]
|
||||
);
|
||||
|
||||
const getItemKey = useCallback((index: number) => (logs[index] ? logs[index].uid : index.toString()), [logs]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAsync, useMeasure } from 'react-use';
|
||||
|
||||
import {
|
||||
@@ -133,9 +133,9 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{isNewVizSuggestionsEnabled
|
||||
? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => (
|
||||
<Fragment key={vizType?.id || `unknown-viz-type-${groupIndex}`}>
|
||||
<div className={styles.vizTypeHeader}>
|
||||
? suggestionsByVizType.map(([vizType, vizTypeSuggestions]) => (
|
||||
<>
|
||||
<div className={styles.vizTypeHeader} key={vizType?.id || 'unknown-viz-type'}>
|
||||
<Text variant="body" weight="medium">
|
||||
{vizType?.info && <img className={styles.vizTypeLogo} src={vizType.info.logos.small} alt="" />}
|
||||
{vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')}
|
||||
@@ -190,7 +190,7 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
</>
|
||||
))
|
||||
: suggestions?.map((suggestion, index) => (
|
||||
<div key={suggestion.hash} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
|
||||
|
||||
Reference in New Issue
Block a user