diff --git a/docs/sources/administration/plugin-management/plugin-sign.md b/docs/sources/administration/plugin-management/plugin-sign.md index 7850996d0f6..54d65baacde 100644 --- a/docs/sources/administration/plugin-management/plugin-sign.md +++ b/docs/sources/administration/plugin-management/plugin-sign.md @@ -25,7 +25,7 @@ Plugin signature verification, also known as _signing_, is a security measure to Learn more at [plugin policies](https://grafana.com/legal/plugins/). -## How does verifiction work? +## How does verification work? At startup, Grafana verifies the signatures of every plugin in the plugin directory. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 16c97f6d10a..b7f55555e07 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | | `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes | -| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | | `opentsdbBackendMigration` | Run queries through the data source backend | | ## Public preview feature toggles diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f757ffb6df6..250dbd20348 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1021,11 +1021,6 @@ "count": 2 } }, - "public/app/core/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "public/app/core/components/AccessControl/PermissionList.tsx": { "no-restricted-syntax": { "count": 1 @@ -4025,11 +4020,6 @@ "count": 1 } }, - "public/app/plugins/datasource/parca/webpack.config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": { "no-restricted-syntax": { "count": 1 @@ -4098,7 +4088,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "public/app/plugins/datasource/tempo/resultTransformer.ts": { diff --git a/eslint.config.js b/eslint.config.js index bd1be26465a..5e44ffebaf4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -585,6 +585,8 @@ module.exports = [ // FIXME: Remove once all enterprise issues are fixed - // we don't have a suppressions file/approach for enterprise code yet ...enterpriseIgnores, + // Ignore decoupled plugin webpack configs + 'public/app/**/webpack.config.ts', ], rules: { 'no-barrel-files/no-barrel-files': 'error', diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6e35e460055..3982bdc33e5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -957,7 +957,8 @@ export interface FeatureToggles { */ alertingBulkActionsInUI?: boolean; /** - * Registers AuthZ /apis endpoint + * Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead + * @deprecated */ kubernetesAuthzApis?: boolean; /** @@ -973,6 +974,18 @@ export interface FeatureToggles { */ kubernetesAuthzZanzanaSync?: boolean; /** + * Registers AuthZ Core Roles /apis endpoint + */ + kubernetesAuthzCoreRolesApi?: boolean; + /** + * Registers AuthZ Roles /apis endpoint + */ + kubernetesAuthzRolesApi?: boolean; + /** + * Registers AuthZ Role Bindings /apis endpoint + */ + kubernetesAuthzRoleBindingsApi?: boolean; + /** * Enables create, delete, and update mutations for resources owned by IAM identity */ kubernetesAuthnMutation?: boolean; @@ -1124,11 +1137,6 @@ export interface FeatureToggles { */ pluginContainers?: boolean; /** - * Run search queries through the tempo backend - * @default false - */ - tempoSearchBackendMigration?: boolean; - /** * Prioritize loading plugins from the CDN before other sources * @default false */ diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index d5a25e2e480..1b06c7daa87 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -153,6 +153,10 @@ interface BaseProps { * Optional way to set how the table is sorted from the beginning. Must be memoized. */ initialSortBy?: Array>; + /** + * Disable the ability to remove sorting on columns (none -> asc -> desc -> asc) + */ + disableSortRemove?: boolean; } interface WithExpandableRow extends BaseProps { @@ -191,6 +195,7 @@ export function InteractiveTable({ showExpandAll = false, fetchData, initialSortBy = [], + disableSortRemove, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { @@ -222,6 +227,7 @@ export function InteractiveTable({ disableMultiSort: true, // If fetchData is provided, we disable client-side sorting manualSortBy: Boolean(fetchData), + disableSortRemove, getRowId, initialState: { hiddenColumns: [ diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts index 47263d4730e..5b84f4c568b 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/types.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -26,4 +26,8 @@ export interface Column { * If the provided function returns `false` the column will be hidden. */ visible?: (data: TableData[]) => boolean; + /** + * Determines starting sort direction when the column header is clicked. + */ + sortDescFirst?: boolean; } diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 2b664b16f6d..050419fe1d1 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -33,6 +33,7 @@ export function getColumns( disableSortBy: !Boolean(column.sortType), width: column.disableGrow ? 0 : undefined, visible: column.visible, + ...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }), ...(column.cell && { Cell: column.cell }), })), ]; diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go index 98e2f1fe9df..adbe54f5a1f 100644 --- a/pkg/apiserver/registry/generic/storage.go +++ b/pkg/apiserver/registry/generic/storage.go @@ -1,26 +1,55 @@ package generic import ( + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/storage" "github.com/grafana/grafana/pkg/apimachinery/utils" ) +// SelectableFieldsOptions allows customizing field selector behavior for a resource. +type SelectableFieldsOptions struct { + // GetAttrs returns labels and fields for the object. + // If nil, the default GetAttrs is used which only exposes metadata.name. + GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error) +} + func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{}) +} + +// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support. +// Use this when you need to filter resources by custom fields like spec.connection.name. +func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) { gv := resourceInfo.GroupVersion() gv.Version = runtime.APIVersionInternal strategy := NewStrategy(scheme, gv) if resourceInfo.IsClusterScoped() { strategy = strategy.WithClusterScope() } + + // Use custom GetAttrs if provided, otherwise use default + var attrFunc storage.AttrFunc + var predicateFunc func(label labels.Selector, field fields.Selector) storage.SelectionPredicate + if fieldOpts.GetAttrs != nil { + attrFunc = fieldOpts.GetAttrs + // Pass nil predicateFunc to use default behavior with custom attrFunc + predicateFunc = nil + } else { + attrFunc = GetAttrs + predicateFunc = Matcher + } + store := ®istry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, + PredicateFunc: predicateFunc, DefaultQualifiedResource: resourceInfo.GroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: resourceInfo.TableConverter(), @@ -28,7 +57,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o UpdateStrategy: strategy, DeleteStrategy: strategy, } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc} if err := store.CompleteWithOptions(options); err != nil { return nil, err } diff --git a/pkg/build/e2e/README.md b/pkg/build/e2e/README.md new file mode 100644 index 00000000000..3edc6946727 --- /dev/null +++ b/pkg/build/e2e/README.md @@ -0,0 +1,20 @@ +## Build artifacts + +Put the resulting tar in your `grafana` OSS path: +```sh +go -C grafana run ./pkg/build/cmd artifacts -a targz:enterprise:linux/amd64 --alpine-base=alpine:3.22 --tag-format='{{ .version }}-{{ .buildID }}-{{ .arch }}' --grafana-dir="${PWD}/grafana" --enterprise-dir="${PWD}/grafana-enterprise" +``` + +Also build the e2e test runner: +```sh +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/ +``` + +And then `chmod +x ./e2e-runner`. + +## Running tests + +Reporting tests with Image Renderer: +```sh +go run ./pkg/build/e2e --suite=e2e/extensions/enterprise/smtp-suite --license=e2e/extensions/enterprise/license.jwt --image-renderer +``` diff --git a/pkg/build/e2e/main.go b/pkg/build/e2e/main.go index 976a7883bdc..7cd85ad2825 100644 --- a/pkg/build/e2e/main.go +++ b/pkg/build/e2e/main.go @@ -138,6 +138,10 @@ func run(ctx context.Context, cmd *cli.Command) error { } if code != 0 { + if stdout, _ := c.Stdout(ctx); len(stdout) > 0 { + log.Printf("e2e test suite stdout:\n%s", stdout) + } + return fmt.Errorf("e2e tests failed with exit code %d", code) } diff --git a/pkg/build/e2e/run.go b/pkg/build/e2e/run.go index e5d36d34b7b..c9bf85df3c8 100644 --- a/pkg/build/e2e/run.go +++ b/pkg/build/e2e/run.go @@ -8,10 +8,10 @@ import ( func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container { command := fmt.Sprintf( - "./e2e-runner cypress --start-grafana=false --cypress-video"+ + "./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+ " --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags) - return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache). + return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache). WithWorkdir("/src"). WithServiceBinding("grafana", svc). WithExec([]string{"yarn", "install", "--immutable"}). diff --git a/pkg/build/e2e/service.go b/pkg/build/e2e/service.go index 31463f63783..f55bd765f3f 100644 --- a/pkg/build/e2e/service.go +++ b/pkg/build/e2e/service.go @@ -99,13 +99,15 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp } if opts.StartImageRenderer { - container = container.WithEnvVariable("START_IMAGE_RENDERER", "true"). - WithExec([]string{"apt-get", "update"}). - WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}) + imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion). + WithExposedPort(8081). + AsService() - if opts.ImageRendererVersion != "" { - container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion) - } + container = container.WithServiceBinding("image-renderer", imageRendererSvc). + WithExec([]string{"apt-get", "update"}). + WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}). + WithEnvVariable("GF_RENDERING_CALLBACK_URL", "http://grafana:3001/"). + WithEnvVariable("GF_RENDERING_SERVER_URL", "http://image-renderer:8081/render") } // We add all GF_ environment variables to allow for overriding Grafana configuration. diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 7c9d8c558c4..7f42d620987 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -5,7 +5,9 @@ import ( "fmt" "maps" "strings" + "time" + "github.com/open-feature/go-sdk/openfeature" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -209,8 +211,16 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi } func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + + // Check if any of the AuthZ APIs are enabled + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + + if enableCoreRolesApi || enableRolesApi || enableRoleBindingsApi { if err := iamv0.AddAuthZKnownTypes(scheme); err != nil { return err } @@ -244,10 +254,16 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) - //nolint:staticcheck // not yet migrated to OpenFeature - enableAuthzApis := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) + + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) // teams + users must have shorter names because they are often used as part of another name opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{ @@ -283,17 +299,21 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } - if enableAuthzApis { + if enableCoreRolesApi { // v0alpha1 if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { return err } + } + if enableRolesApi { // Role registration is delegated to the RoleApiInstaller if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { return err } + } + if enableRoleBindingsApi { if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { return err } diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index 45d8572e94a..efb2bd52697 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -71,6 +71,98 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C return _c } +// HasDirPathFailedDeletion provides a mock function with given fields: folderPath +func (_m *MockJobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + ret := _m.Called(folderPath) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedDeletion") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(folderPath) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedDeletion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedDeletion' +type MockJobProgressRecorder_HasDirPathFailedDeletion_Call struct { + *mock.Call +} + +// HasDirPathFailedDeletion is a helper method to define mock.On call +// - folderPath string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedDeletion(folderPath interface{}) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + return &MockJobProgressRecorder_HasDirPathFailedDeletion_Call{Call: _e.mock.On("HasDirPathFailedDeletion", folderPath)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Run(run func(folderPath string)) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(run) + return _c +} + +// HasDirPathFailedCreation provides a mock function with given fields: path +func (_m *MockJobProgressRecorder) HasDirPathFailedCreation(path string) bool { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedCreation") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedCreation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedCreation' +type MockJobProgressRecorder_HasDirPathFailedCreation_Call struct { + *mock.Call +} + +// HasDirPathFailedCreation is a helper method to define mock.On call +// - path string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedCreation(path interface{}) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + return &MockJobProgressRecorder_HasDirPathFailedCreation_Call{Call: _e.mock.On("HasDirPathFailedCreation", path)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Run(run func(path string)) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(run) + return _c +} + // Record provides a mock function with given fields: ctx, result func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { _m.Called(ctx, result) diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 2cb9dc9ddcf..3ba61eb6278 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "errors" "fmt" "sync" "time" @@ -9,6 +10,8 @@ import ( "github.com/grafana/grafana-app-sdk/logging" 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/registry/apis/provisioning/resources" ) // maybeNotifyProgress will only notify if a certain amount of time has passed @@ -58,6 +61,8 @@ type jobProgressRecorder struct { notifyImmediatelyFn ProgressFn maybeNotifyFn ProgressFn summaries map[string]*provisioning.JobResourceSummary + failedCreations []string // Tracks folder paths that failed to be created + failedDeletions []string // Tracks resource paths that failed to be deleted } func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder { @@ -84,10 +89,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu if result.Error != nil { shouldLogError = true logErr = result.Error - if len(r.errors) < 20 { - r.errors = append(r.errors, result.Error.Error()) + + // Don't count ignored actions as errors in error count or error list + if result.Action != repository.FileActionIgnored { + if len(r.errors) < 20 { + r.errors = append(r.errors, result.Error.Error()) + } + r.errorCount++ + } + + // Automatically track failed operations based on error type and action + // Check if this is a PathCreationError (folder creation failure) + var pathErr *resources.PathCreationError + if errors.As(result.Error, &pathErr) { + r.failedCreations = append(r.failedCreations, pathErr.Path) + } + + // Track failed deletions, any deletion will stop the deletion of the parent folder (as it won't be empty) + if result.Action == repository.FileActionDeleted { + r.failedDeletions = append(r.failedDeletions, result.Path) } - r.errorCount++ } r.updateSummary(result) @@ -112,6 +133,8 @@ func (r *jobProgressRecorder) ResetResults() { r.errorCount = 0 r.errors = nil r.summaries = make(map[string]*provisioning.JobResourceSummary) + r.failedCreations = nil + r.failedDeletions = nil } func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) { @@ -309,3 +332,29 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision return jobStatus } + +// HasDirPathFailedCreation checks if a path is nested under any failed folder creation +func (r *jobProgressRecorder) HasDirPathFailedCreation(path string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedCreation := range r.failedCreations { + if safepath.InDir(path, failedCreation) { + return true + } + } + return false +} + +// HasDirPathFailedDeletion checks if any resource deletions failed under a folder path +func (r *jobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedDeletion := range r.failedDeletions { + if safepath.InDir(failedDeletion, folderPath) { + return true + } + } + return false +} diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go index 7e849491bbe..0879ba111af 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_test.go +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -7,6 +7,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/pkg/registry/apis/provisioning/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -252,3 +253,221 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) { require.NotNil(t, finalStatus.Warnings) assert.Len(t, finalStatus.Warnings, 1) } + +func TestJobProgressRecorderFolderFailureTracking(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record a folder creation failure with PathCreationError + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + // Record another PathCreationError for a different folder + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Record a deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Record another deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder4/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify failed creations are tracked + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 2) + assert.Contains(t, recorder.failedCreations, "folder1/") + assert.Contains(t, recorder.failedCreations, "folder2/subfolder/") + + // Verify failed deletions are tracked + assert.Len(t, recorder.failedDeletions, 2) + assert.Contains(t, recorder.failedDeletions, "folder3/file1.json") + assert.Contains(t, recorder.failedDeletions, "folder4/subfolder/file2.json") + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderHasDirPathFailedCreation(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed creations via Record + pathErr1 := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr1, + }) + + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Test nested paths + assert.True(t, recorder.HasDirPathFailedCreation("folder1/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder1/nested/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder2/subfolder/file.json")) + + // Test non-nested paths + assert.False(t, recorder.HasDirPathFailedCreation("folder2/file2.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder2/othersubfolder/inside.json")) + assert.False(t, recorder.HasDirPathFailedCreation("other/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder3/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("file.json")) +} + +func TestJobProgressRecorderHasDirPathFailedDeletion(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed deletions via Record + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/nested/deep/file3.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Test folder paths with failed deletions + assert.True(t, recorder.HasDirPathFailedDeletion("folder1/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/")) + + // Test folder paths without failed deletions + assert.False(t, recorder.HasDirPathFailedDeletion("other/")) + assert.False(t, recorder.HasDirPathFailedDeletion("different/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/anotherdeep/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/insidedeep/")) +} + +func TestJobProgressRecorderResetResults(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add some data via Record + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify data is stored + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 1) + assert.Len(t, recorder.failedDeletions, 1) + recorder.mu.RUnlock() + + // Reset results + recorder.ResetResults() + + // Verify data is cleared + recorder.mu.RLock() + assert.Nil(t, recorder.failedCreations) + assert.Nil(t, recorder.failedDeletions) + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderIgnoredActionsDontCountAsErrors(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record an ignored action with error + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionIgnored, + Error: assert.AnError, + }) + + // Record a real error for comparison + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file2.json", + Action: repository.FileActionCreated, + Error: assert.AnError, + }) + + // Verify error count doesn't include ignored actions + recorder.mu.RLock() + assert.Equal(t, 1, recorder.errorCount, "ignored actions should not be counted as errors") + assert.Len(t, recorder.errors, 1, "ignored action errors should not be in error list") + recorder.mu.RUnlock() +} diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index e1992395efd..90b50aa4ee7 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -29,6 +29,10 @@ type JobProgressRecorder interface { StrictMaxErrors(maxErrors int) SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) Complete(ctx context.Context, err error) provisioning.JobStatus + // HasDirPathFailedCreation checks if a path has any folder creations that failed + HasDirPathFailedCreation(path string) bool + // HasDirPathFailedDeletion checks if a folderPath has any folder deletions that failed + HasDirPathFailedDeletion(folderPath string) bool } // Worker is a worker that can process a job diff --git a/pkg/registry/apis/provisioning/jobs/sync/full.go b/pkg/registry/apis/provisioning/jobs/sync/full.go index 10aad46693b..50ab5c39bcf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full.go @@ -75,11 +75,47 @@ func FullSync( return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics) } +// shouldSkipChange checks if a change should be skipped based on previous failures on parent/child folders. +// If there is a previous failure on the path, we don't need to process the change as it will fail anyway. +func shouldSkipChange(ctx context.Context, change ResourceFileChange, progress jobs.JobProgressRecorder, tracer tracing.Tracer) bool { + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_nested_resource") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + return true + } + + if change.Action == repository.FileActionDeleted && safepath.IsDir(change.Path) && progress.HasDirPathFailedDeletion(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_folder_with_failed_deletions") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Warning: fmt.Errorf("folder was not processed because children resources in its path could not be deleted"), + }) + skipSpan.End() + return true + } + + return false +} + func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) { if ctx.Err() != nil { return } + if shouldSkipChange(ctx, change, progress, tracer) { + return + } + if change.Action == repository.FileActionDeleted { deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete") result := jobs.JobResourceResult{ @@ -138,6 +174,7 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc ensureFolderSpan.RecordError(err) ensureFolderSpan.End() progress.Record(ctx, result) + return } @@ -253,8 +290,6 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res } func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) error { - logger := logging.FromContext(ctx) - for _, folder := range folders { if ctx.Err() != nil { return ctx.Err() @@ -264,23 +299,9 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli return err } - folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - - applyChange(folderCtx, folder, clients, repositoryResources, progress, tracer) - - if folderCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", folder.Path, "action", folder.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: folder.Path, - Action: folder.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } - - cancel() + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer) + }) } return nil @@ -318,7 +339,9 @@ loop: defer wg.Done() defer func() { <-sem }() - applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger) + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer) + }) }(change) } @@ -331,21 +354,10 @@ loop: return ctx.Err() } -func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) { - changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) +// wrapWithTimeout wraps a function call with a timeout context +func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) { + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - applyChange(changeCtx, change, clients, repositoryResources, progress, tracer) - - if changeCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", change.Path, "action", change.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: change.Path, - Action: change.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } + fn(timeoutCtx) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go new file mode 100644 index 00000000000..2bc0ea8779e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go @@ -0,0 +1,432 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8testing "k8s.io/client-go/testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestFullSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When a folder fails to be created with PathCreationError, all nested resources are skipped +- Nested resources are recorded with FileActionIgnored and error "folder was not processed because children resources in its path could not be deleted" +- Only the folder creation error counts toward error limits +- Nested resource skips do NOT count toward error limits + +FOLDER DELETION FAILURES: +- When a file deletion fails, it's tracked in failedDeletions +- When cleaning up folders, we check HasDirPathFailedDeletion() +- If children failed to delete, folder deletion is skipped with FileActionIgnored +- This prevents orphaning resources that still exist + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- If a folder creation fails, deletion operations for resources in that folder still proceed +- This is because the resource might already exist from a previous sync +- Only creations/updates/renames are affected by failed folder creation + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError and adds to failedCreations +- Record() automatically detects deletion failures and adds to failedDeletions +- No manual calls to AddFailedCreation/AddFailedDeletion needed +*/ +func TestFullSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *dynamicfake.FakeDynamicClient) + changes []ResourceFileChange + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When folder1/ fails to create, folder1/file.json should be skipped with FileActionIgnored", + changes: []ResourceFileChange{ + {Path: "folder1/file.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First, check if nested under failed creation - not yet + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + + // WriteResourceFromFile fails with PathCreationError for folder1/ + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // File will be recorded with error, triggering automatic tracking of folder1/ failure + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil && r.Action == repository.FileActionCreated + })).Return().Once() + }, + }, + { + name: "folder creation fails, multiple nested resources skipped", + description: "When folder1/ fails to create, all nested resources (subfolder, files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + {Path: "folder1/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers folder creation failure + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Subsequent files in same folder are skipped + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file3.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "file deletion failure tracked", + description: "When a file deletion fails, it's automatically tracked in failedDeletions", + changes: []ResourceFileChange{ + { + Path: "folder1/file.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file1", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // File deletion fails + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + // File deletion recorded with error, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ fails to create, deletion of folder1/file2.json still proceeds (resource might exist from previous sync)", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + { + Path: "folder1/file2.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file2", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + // Note: deletion will fail because resource doesn't exist, but that's fine for this test + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // Record deletion attempt (will have error since resource doesn't exist, but that's ok) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && + r.Action == repository.FileActionDeleted + // Not checking r.Error because resource doesn't exist in fake client + })).Return().Once() + }, + }, + { + name: "multi-level nesting - all skipped", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + changes: []ResourceFileChange{ + {Path: "level1/file1.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/file2.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/level3/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "level1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file1.json" && r.Error != nil + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file2.json", "level1/level2/level3/file3.json"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + changes: []ResourceFileChange{ + {Path: "success/file1.json", Action: repository.FileActionCreated}, + {Path: "failure/file2.json", Action: repository.FileActionCreated}, + {Path: "failure/nested/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", ""). + Return("resource1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file2.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("WriteResourceFromFile", mock.Anything, "failure/file2.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file2.json" && r.Error != nil + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file3.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder creation fails with explicit folder in changes", + description: "When folder1/ is explicitly in changes and fails to create, all nested resources (subfolders and files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/", Action: repository.FileActionCreated}, + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + progress.On("HasDirPathFailedCreation", "folder1/").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "folder1/").Return("", folderErr).Once() + + progress.On("HasDirPathFailedCreation", "folder1/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Error != nil + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder deletion prevented when child deletion fails", + description: "When a file deletion fails, folder deletion is skipped with FileActionIgnored to prevent orphaning resources", + changes: []ResourceFileChange{ + { + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}, + }, + {Path: "folder1/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "folder1", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "multiple folder deletion failures", + description: "When multiple independent folders have child deletion failures, all folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder1/", Action: repository.FileActionDeleted}, + {Path: "folder2/file2.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file2", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder2/", Action: repository.FileActionDeleted}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + for _, path := range []string{"folder1/file1.json", "folder2/file2.json"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Error != nil + })).Return().Once() + } + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "folder2/").Return(true).Once() + + for _, path := range []string{"folder1/", "folder2/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "nested subfolder deletion failure", + description: "When a file deletion fails in a nested subfolder, both the subfolder and parent folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "parent/subfolder/file.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "parent/subfolder/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "subfolder", Group: "folder.grafana.app", Resource: "Folder"}}, + {Path: "parent/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "parent", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "parent/subfolder/file.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "parent/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "parent/").Return(true).Once() + + for _, path := range []string{"parent/subfolder/", "parent/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) + + repo := repository.NewMockRepository(t) + repoResources := resources.NewMockRepositoryResources(t) + clients := resources.NewMockResourceClients(t) + progress := jobs.NewMockJobProgressRecorder(t) + compareFn := NewMockCompareFn(t) + + repo.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "test-repo"}, + Spec: provisioning.RepositorySpec{Title: "Test Repo"}, + }) + + tt.setupMocks(repo, repoResources, clients, progress, dynamicClient) + + compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + err := FullSync(context.Background(), repo, compareFn.Execute, clients, "ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_test.go index aaa61ee61db..d045c67c6be 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full_test.go @@ -213,6 +213,10 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo return nil }) + progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool { + return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" + })).Return(false).Maybe() + repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool { return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" }), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe() @@ -235,6 +239,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -259,6 +264,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -285,6 +291,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -309,6 +316,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -335,6 +343,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil) progress.On("Record", mock.Anything, jobs.JobResourceResult{ @@ -357,6 +366,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On( "EnsureFolderPathExist", @@ -581,6 +591,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -640,6 +651,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -695,6 +707,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", ""). Run(func(args mock.Arguments) { @@ -708,19 +721,13 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }). Return("", schema.GroupVersionKind{}, context.DeadlineExceeded) + // applyChange records the error from WriteResourceFromFile progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionCreated && result.Path == "dashboards/slow.json" && result.Error != nil && result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded" })).Return().Once() - - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Path == "dashboards/slow.json" && - result.Error != nil && - result.Error.Error() == "operation timed out after 15 seconds" - })).Return().Once() }, }, } diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental.go b/pkg/registry/apis/provisioning/jobs/sync/incremental.go index daa94d94636..5ae33f1e4d1 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental.go @@ -60,7 +60,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef if len(affectedFolders) > 0 { cleanupStart := time.Now() span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders)))) - err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer) + err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress) metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart)) if err != nil { return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err)) @@ -85,6 +85,20 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil return nil, tracing.Error(span, err) } + // Check if this resource is nested under a failed folder creation + // This only applies to creation/update/rename operations, not deletions + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + // Skip this resource since its parent folder failed to be created + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.incremental.skip_nested_resource") + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + continue + } + if err := resources.IsPathSupported(change.Path); err != nil { ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist") // Maintain the safe segment for empty folders @@ -98,7 +112,15 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil if err != nil { ensureFolderSpan.RecordError(err) ensureFolderSpan.End() - return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err)) + + progress.Record(ensureFolderCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Error: err, + }) + continue } progress.Record(ensureFolderCtx, jobs.JobResourceResult{ @@ -185,6 +207,7 @@ func cleanupOrphanedFolders( affectedFolders map[string]string, repositoryResources resources.RepositoryResources, tracer tracing.Tracer, + progress jobs.JobProgressRecorder, ) error { ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders") defer span.End() @@ -198,6 +221,12 @@ func cleanupOrphanedFolders( for path, folderName := range affectedFolders { span.SetAttributes(attribute.String("folder", folderName)) + // Check if any resources under this folder failed to delete + if progress.HasDirPathFailedDeletion(path) { + span.AddEvent("skipping orphaned folder cleanup: a child resource in its path failed to be deleted") + continue + } + // if we can no longer find the folder in git, then we can delete it from grafana _, err := readerRepo.Read(ctx, path, "") if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go new file mode 100644 index 00000000000..ff4212eff1e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go @@ -0,0 +1,623 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestIncrementalSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When EnsureFolderPathExist fails with PathCreationError, the path is tracked +- Subsequent resources under that path are skipped with FileActionIgnored +- Only the initial folder creation error counts toward error limits +- WriteResourceFromFile can also return PathCreationError for implicit folder creation + +FOLDER DELETION FAILURES (cleanupOrphanedFolders): +- When RemoveResourceFromFile fails, path is tracked in failedDeletions +- In cleanupOrphanedFolders, HasDirPathFailedDeletion() is checked before RemoveFolder +- If children failed to delete, folder cleanup is skipped with a span event + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- HasDirPathFailedCreation is NOT checked for FileActionDeleted +- Deletions proceed even if their parent folder failed to be created +- This handles cleanup of resources that exist from previous syncs + +RENAME OPERATIONS: +- RenameResourceFile can return PathCreationError for the destination folder +- Renames are affected by failed destination folder creation +- Renames are NOT skipped due to source folder creation failures + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError via errors.As() and adds to failedCreations +- Record() automatically detects FileActionDeleted with error and adds to failedDeletions +- No manual tracking calls needed +*/ +func TestIncrementalSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When unsupported/ fails to create via EnsureFolderPathExist, nested file is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/nested/file2.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers folder creation which fails + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + // First file recorded with error (note: error is from folder creation, but recorded against file) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && + r.Action == repository.FileActionIgnored && + r.Error != nil + })).Return().Once() + + // Second file is skipped because parent folder failed + progress.On("HasDirPathFailedCreation", "unsupported/nested/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/nested/file2.txt" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "WriteResourceFromFile returns PathCreationError, nested resources skipped", + description: "When WriteResourceFromFile implicitly creates a folder and fails, nested resources are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/nested/file3.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file write fails with PathCreationError + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // First file recorded with error, automatically tracked + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionCreated && + r.Error != nil + })).Return().Once() + + // Subsequent files are skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/nested/file3.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + { + name: "file deletion fails, folder cleanup skipped", + description: "When RemoveResourceFromFile fails, cleanupOrphanedFolders skips folder removal", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // File deletion fails (deletions don't check HasDirPathFailedCreation) + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + // Error recorded, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + + // During cleanup, folder deletion is skipped + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + // Note: RemoveFolder should NOT be called (verified via AssertNotCalled in test) + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ creation fails, deletion of folder1/old.json still proceeds", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/new.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/new.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/new.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/new.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && + r.Action == repository.FileActionDeleted && + r.Error == nil // Deletion succeeds! + })).Return().Once() + }, + }, + { + name: "multi-level nesting cascade", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file.txt", "level1/level2/level3/file.txt"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "rename with failed destination folder", + description: "When RenameResourceFile fails with PathCreationError for destination, rename is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + { + Action: repository.FileActionRenamed, + Path: "newfolder/file.json", + PreviousPath: "oldfolder/file.json", + Ref: "new-ref", + PreviousRef: "old-ref", + }, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename fails with PathCreationError for destination folder + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "newfolder/", Err: fmt.Errorf("permission denied")} + repoResources.On("RenameResourceFile", mock.Anything, "oldfolder/file.json", "old-ref", "newfolder/file.json", "new-ref"). + Return("", "", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && + r.Action == repository.FileActionRenamed && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "renamed file still checked, subsequent nested resources skipped", + description: "After rename fails for folder1/file.json, other folder1/* files are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "folder1/file1.json", PreviousPath: "old/file1.json", Ref: "new-ref", PreviousRef: "old-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename is NOT skipped for creation failures (it's checking the destination path) + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + // Second file also skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runHierarchicalErrorHandlingTest(t, tt) + }) + } +} + +type compositeRepoForTest struct { + *repository.MockVersioned + *repository.MockReader +} + +func runHierarchicalErrorHandlingTest(t *testing.T, tt struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string +}) { + var repo repository.Versioned + mockVersioned := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + // For tests that need cleanup (folder deletion), use composite repo + if tt.name == "file deletion fails, folder cleanup skipped" { + mockReader := repository.NewMockReader(t) + repo = &compositeRepoForTest{ + MockVersioned: mockVersioned, + MockReader: mockReader, + } + } else { + repo = mockVersioned + } + + mockVersioned.On("CompareFiles", mock.Anything, tt.previousRef, tt.currentRef).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + tt.setupMocks(mockVersioned, repoResources, progress) + + err := IncrementalSync(context.Background(), repo, tt.previousRef, tt.currentRef, repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + // For deletion tests, verify RemoveFolder was NOT called + if tt.name == "file deletion fails, folder cleanup skipped" { + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) + } +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation tests nested resource skipping +func TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/subfolder/file2.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/file3.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "other/file.json", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/subfolder/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/subfolder/file2.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file3.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "other/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "other/file.json", "new-ref"). + Return("test-resource", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "other/file.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion tests folder cleanup prevention +func TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion(t *testing.T) { + mockVersioned := repository.NewMockVersioned(t) + mockReader := repository.NewMockReader(t) + repo := &compositeRepoForTest{MockVersioned: mockVersioned, MockReader: mockReader} + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + } + + mockVersioned.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && r.Action == repository.FileActionDeleted && + r.Error != nil && r.Error.Error() == "removing resource from file dashboards/file1.json: permission denied" + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) +} + +// TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure tests deletions proceed despite creation failures +func TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 2).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "new-ref"). + Return("", schema.GroupVersionKind{}, &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil + })).Return().Once() + + // Deletion should NOT be skipped (not checking HasDirPathFailedCreation for deletions) + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && r.Action == repository.FileActionDeleted && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting tests multi-level cascade +func TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 3).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/level3/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/level3/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure tests partial failures +func TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation tests rename operations affected by folder failures +func TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "newfolder/file.json", PreviousPath: "oldfolder/file.json", Ref: "new-ref", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go index f694d7f5068..38c537635cf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go @@ -92,6 +92,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation checks + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + progress.On("HasDirPathFailedCreation", "alerts/alert.yaml").Return(false) + // Mock successful resource writes repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -127,6 +131,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) + // Mock folder creation repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("test-folder", nil) @@ -161,6 +168,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", ".unsupported/path/file.txt").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: ".unsupported/path/file.txt", @@ -222,6 +232,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false) + // Mock resource rename repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref"). Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -254,6 +267,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/ignored.json").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: "dashboards/ignored.json", @@ -277,16 +294,28 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) // Mock folder creation error repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("", fmt.Errorf("failed to create folder")) + // Mock progress recording with error + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Path == "unsupported/path/file.txt" && + result.Error != nil && + result.Error.Error() == "failed to create folder" + })).Return() + progress.On("TooManyErrors").Return(nil) }, previousRef: "old-ref", currentRef: "new-ref", - expectedError: "unable to create empty file folder: failed to create folder", + expectedCalls: 1, }, { name: "error writing resource", @@ -303,6 +332,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + // Mock resource write error repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed")) @@ -372,7 +404,8 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() - // Mock too many errors + + // Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred")) }, previousRef: "old-ref", @@ -428,6 +461,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder is not found in git, there should be a call to remove the folder from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -453,6 +489,10 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder still exists in git, there should not be a call to delete it from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return(&repository.FileInfo{}, nil) @@ -485,6 +525,13 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref"). Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil) + progress.On("Record", mock.Anything, mock.Anything).Return() + progress.On("TooManyErrors").Return(nil) + + // Mock HasDirPathFailedDeletion checks for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + progress.On("HasDirPathFailedDeletion", "alerts/").Return(false) + // both not found in git, both should be deleted repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -492,9 +539,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").Return(nil) - - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(nil) }, }, } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 026797eb474..e54a8c2fc28 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -559,6 +559,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { return err } + // Register custom field label conversion for Repository to enable field selectors like spec.connection.name + err = scheme.AddFieldLabelConversionFunc( + provisioning.SchemeGroupVersion.WithKind("Repository"), + func(label, value string) (string, string, error) { + switch label { + case "metadata.name", "metadata.namespace", "spec.connection.name": + return label, value, nil + default: + return "", "", fmt.Errorf("field label not supported for Repository: %s", label) + } + }, + ) + if err != nil { + return err + } + metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion) // Only 1 version (for now?) return scheme.SetVersionPriority(provisioning.SchemeGroupVersion) @@ -569,10 +585,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string { } func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { - repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter) + // Create repository storage with custom field selectors (e.g., spec.connection.name) + repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields( + opts.Scheme, + provisioning.RepositoryResourceInfo, + opts.OptsGetter, + grafanaregistry.SelectableFieldsOptions{ + GetAttrs: RepositoryGetAttrs, + }, + ) if err != nil { return fmt.Errorf("failed to create repository storage: %w", err) } + repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.store = repositoryStorage diff --git a/pkg/registry/apis/provisioning/repository_fields.go b/pkg/registry/apis/provisioning/repository_fields.go new file mode 100644 index 00000000000..0849c558e2f --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields.go @@ -0,0 +1,44 @@ +package provisioning + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// RepositoryToSelectableFields returns a field set that can be used for field selectors. +// This includes standard metadata fields plus custom fields like spec.connection.name. +func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set { + objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true) + + // Add custom selectable fields + specificFields := fields.Set{ + "spec.connection.name": getConnectionName(obj), + } + + return generic.MergeFieldsSets(objectMetaFields, specificFields) +} + +// getConnectionName safely extracts the connection name from a Repository. +// Returns empty string if no connection is configured. +func getConnectionName(obj *provisioning.Repository) string { + if obj == nil || obj.Spec.Connection == nil { + return "" + } + return obj.Spec.Connection.Name +} + +// RepositoryGetAttrs returns labels and fields of a Repository object. +// This is used by the storage layer for filtering. +func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + repo, ok := obj.(*provisioning.Repository) + if !ok { + return nil, nil, fmt.Errorf("given object is not a Repository") + } + return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil +} diff --git a/pkg/registry/apis/provisioning/repository_fields_test.go b/pkg/registry/apis/provisioning/repository_fields_test.go new file mode 100644 index 00000000000..89a2271477c --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields_test.go @@ -0,0 +1,184 @@ +package provisioning + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestGetConnectionName(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expected string + }{ + { + name: "nil repository returns empty string", + repo: nil, + expected: "", + }, + { + name: "repository without connection returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + }, + }, + expected: "", + }, + { + name: "repository with connection returns connection name", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + }, + expected: "my-connection", + }, + { + name: "repository with empty connection name returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "", + }, + }, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getConnectionName(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRepositoryToSelectableFields(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expectedFields map[string]string + }{ + { + name: "includes metadata.name and metadata.namespace", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + }, + expectedFields: map[string]string{ + "metadata.name": "test-repo", + "metadata.namespace": "default", + "spec.connection.name": "", + }, + }, + { + name: "includes spec.connection.name when set", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-with-connection", + Namespace: "org-1", + }, + Spec: provisioning.RepositorySpec{ + Title: "Repo With Connection", + Connection: &provisioning.ConnectionInfo{ + Name: "github-connection", + }, + }, + }, + expectedFields: map[string]string{ + "metadata.name": "repo-with-connection", + "metadata.namespace": "org-1", + "spec.connection.name": "github-connection", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := RepositoryToSelectableFields(tt.repo) + + for key, expectedValue := range tt.expectedFields { + actualValue, exists := fields[key] + assert.True(t, exists, "field %s should exist", key) + assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key) + } + }) + } +} + +func TestRepositoryGetAttrs(t *testing.T) { + t.Run("returns error for non-Repository object", func(t *testing.T) { + // Pass a different runtime.Object type instead of a Repository + connection := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-a-repository", + }, + } + _, _, err := RepositoryGetAttrs(connection) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a Repository") + }) + + t.Run("returns labels and fields for valid Repository", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + Labels: map[string]string{ + "app": "grafana", + "env": "test", + }, + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + } + + labels, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + + // Check labels + assert.Equal(t, "grafana", labels["app"]) + assert.Equal(t, "test", labels["env"]) + + // Check fields + assert.Equal(t, "test-repo", fields["metadata.name"]) + assert.Equal(t, "default", fields["metadata.namespace"]) + assert.Equal(t, "my-connection", fields["spec.connection.name"]) + }) + + t.Run("returns empty connection name when not set", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + } + + _, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + assert.Equal(t, "", fields["spec.connection.name"]) + }) +} diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index 8b8f4201745..b4a06f78691 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -20,6 +20,21 @@ import ( const MaxNumberOfFolders = 10000 +// PathCreationError represents an error that occurred while creating a folder path. +// It contains the path that failed and the underlying error. +type PathCreationError struct { + Path string + Err error +} + +func (e *PathCreationError) Unwrap() error { + return e.Err +} + +func (e *PathCreationError) Error() string { + return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err) +} + type FolderManager struct { repo repository.ReaderWriter tree FolderTree @@ -73,7 +88,11 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str } if err := fm.EnsureFolderExists(ctx, f, parent); err != nil { - return fmt.Errorf("ensure folder exists: %w", err) + // Wrap in PathCreationError to indicate which path failed + return &PathCreationError{ + Path: f.Path, + Err: fmt.Errorf("ensure folder exists: %w", err), + } } fm.tree.Add(f, parent) diff --git a/pkg/registry/apis/provisioning/resources/folders_test.go b/pkg/registry/apis/provisioning/resources/folders_test.go new file mode 100644 index 00000000000..ed593a7d26c --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/folders_test.go @@ -0,0 +1,68 @@ +package resources_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/stretchr/testify/require" +) + +func TestPathCreationError(t *testing.T) { + t.Run("Error method returns formatted message", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + expectedMsg := "failed to create path grafana/folder-1: underlying error" + require.Equal(t, expectedMsg, pathErr.Error()) + }) + + t.Run("Unwrap returns underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + unwrapped := pathErr.Unwrap() + require.Equal(t, underlyingErr, unwrapped) + require.EqualError(t, unwrapped, "underlying error") + }) + + t.Run("errors.Is finds underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + require.True(t, errors.Is(pathErr, underlyingErr)) + require.False(t, errors.Is(pathErr, fmt.Errorf("different error"))) + }) + + t.Run("errors.As extracts PathCreationError", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + var extractedErr *resources.PathCreationError + require.True(t, errors.As(pathErr, &extractedErr)) + require.NotNil(t, extractedErr) + require.Equal(t, "grafana/folder-1", extractedErr.Path) + require.Equal(t, underlyingErr, extractedErr.Err) + }) + + t.Run("errors.As returns false for non-PathCreationError", func(t *testing.T) { + regularErr := fmt.Errorf("regular error") + + var extractedErr *resources.PathCreationError + require.False(t, errors.As(regularErr, &extractedErr)) + require.Nil(t, extractedErr) + }) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 885079a5f5a..ed591908042 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1585,8 +1585,8 @@ var ( }, { Name: "kubernetesAuthzApis", - Description: "Registers AuthZ /apis endpoint", - Stage: FeatureStageExperimental, + Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + Stage: FeatureStageDeprecated, Owner: identityAccessTeam, HideFromDocs: true, }, @@ -1611,6 +1611,27 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "kubernetesAuthzCoreRolesApi", + Description: "Registers AuthZ Core Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRolesApi", + Description: "Registers AuthZ Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRoleBindingsApi", + Description: "Registers AuthZ Role Bindings /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, { Name: "kubernetesAuthnMutation", Description: "Enables create, delete, and update mutations for resources owned by IAM identity", @@ -1859,14 +1880,6 @@ var ( Expression: "false", RequiresRestart: true, }, - { - Name: "tempoSearchBackendMigration", - Description: "Run search queries through the tempo backend", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOSSBigTent, - Expression: "false", - RequiresRestart: true, - }, { Name: "cdnPluginsLoadFirst", Description: "Prioritize loading plugins from the CDN before other sources", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8ddc448ef52..15376f39fc1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -217,10 +217,13 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true -kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzApis,deprecated,@grafana/identity-access-team,false,false,false kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzCoreRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRoleBindingsApi,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false @@ -253,7 +256,6 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false -tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c42229d8870..e778a6fc3d1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -631,7 +631,7 @@ const ( FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt" // FlagKubernetesAuthzApis - // Registers AuthZ /apis endpoint + // Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead FlagKubernetesAuthzApis = "kubernetesAuthzApis" // FlagKubernetesAuthZHandlerRedirect @@ -646,6 +646,18 @@ const ( // Enable sync of Zanzana authorization store on AuthZ CRD mutations FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync" + // FlagKubernetesAuthzCoreRolesApi + // Registers AuthZ Core Roles /apis endpoint + FlagKubernetesAuthzCoreRolesApi = "kubernetesAuthzCoreRolesApi" + + // FlagKubernetesAuthzRolesApi + // Registers AuthZ Roles /apis endpoint + FlagKubernetesAuthzRolesApi = "kubernetesAuthzRolesApi" + + // FlagKubernetesAuthzRoleBindingsApi + // Registers AuthZ Role Bindings /apis endpoint + FlagKubernetesAuthzRoleBindingsApi = "kubernetesAuthzRoleBindingsApi" + // FlagKubernetesAuthnMutation // Enables create, delete, and update mutations for resources owned by IAM identity FlagKubernetesAuthnMutation = "kubernetesAuthnMutation" @@ -730,10 +742,6 @@ const ( // Enables running plugins in containers FlagPluginContainers = "pluginContainers" - // FlagTempoSearchBackendMigration - // Run search queries through the tempo backend - FlagTempoSearchBackendMigration = "tempoSearchBackendMigration" - // FlagCdnPluginsLoadFirst // Prioritize loading plugins from the CDN before other sources FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 09c4d0c9760..124a4977c07 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1951,11 +1951,27 @@ { "metadata": { "name": "kubernetesAuthzApis", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-06-18T07:43:01Z" + "resourceVersion": "1767954559317", + "creationTimestamp": "2025-06-18T07:43:01Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC" + } }, "spec": { - "description": "Registers AuthZ /apis endpoint", + "description": "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + "stage": "deprecated", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzCoreRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Core Roles /apis endpoint", "stage": "experimental", "codeowner": "@grafana/identity-access-team", "hideFromDocs": true @@ -1975,6 +1991,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzRoleBindingsApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Role Bindings /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Roles /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthzZanzanaSync", diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index 02c5436badb..99f32dffa93 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -559,3 +559,175 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { assert.True(t, final.Status.Health.Healthy, "connection should remain healthy") }) } + +func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + + // Create a connection first + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-conn-for-field-selector", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "789012", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "test-private-key", + }, + }, + }} + + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.NoError(t, err, "failed to create connection") + + t.Cleanup(func() { + // Clean up repositories first + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{}) + // Then clean up the connection + _ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{}) + }) + + // Create a repository WITH the connection + repoWithConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "test-conn-for-field-selector", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions) + require.NoError(t, err, "failed to create repository with connection") + + // Create a repository WITHOUT the connection + repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-without-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo Without Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions) + require.NoError(t, err, "failed to create repository without connection") + + // Create a repository with a DIFFERENT connection name (non-existent) + repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-different-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Different Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "some-other-connection", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions) + require.NoError(t, err, "failed to create repository with different connection") + + t.Run("filter repositories by spec.connection.name", func(t *testing.T) { + // List repositories with field selector for the specific connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=test-conn-for-field-selector", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should only return the repository with the matching connection + assert.Len(t, list.Items, 1, "should return exactly one repository") + assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository") + }) + + t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) { + // List repositories with field selector for a non-existent connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=non-existent-connection", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should return empty list + assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection") + }) + + t.Run("filter repositories by empty connection name", func(t *testing.T) { + // List repositories with field selector for empty connection (repos without connection) + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=", + }) + require.NoError(t, err, "failed to list repositories with empty connection field selector") + + // Should return the repository without a connection + assert.Len(t, list.Items, 1, "should return exactly one repository without connection") + assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection") + }) + + t.Run("list all repositories without field selector", func(t *testing.T) { + // List all repositories without field selector + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list all repositories") + + // Should return all three repositories + assert.Len(t, list.Items, 3, "should return all three repositories") + + names := make([]string, len(list.Items)) + for i, item := range list.Items { + names[i] = item.GetName() + } + assert.Contains(t, names, "repo-with-connection") + assert.Contains(t, names, "repo-without-connection") + assert.Contains(t, names, "repo-with-different-connection") + }) +} diff --git a/public/app/api/clients/collections/v1alpha1/index.ts b/public/app/api/clients/collections/v1alpha1/index.ts index cd2102f6b29..c23fee00241 100644 --- a/public/app/api/clients/collections/v1alpha1/index.ts +++ b/public/app/api/clients/collections/v1alpha1/index.ts @@ -1,7 +1,7 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1'; import { t } from '@grafana/i18n'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { diff --git a/public/app/api/clients/playlist/v0alpha1/index.ts b/public/app/api/clients/playlist/v0alpha1/index.ts index 8f264203f8d..67a5973190e 100644 --- a/public/app/api/clients/playlist/v0alpha1/index.ts +++ b/public/app/api/clients/playlist/v0alpha1/index.ts @@ -1,8 +1,8 @@ import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1'; import { getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { contextSrv } from '../../../../core/services/context_srv'; import { handleError } from '../../../utils'; diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index 7f41904cf94..1a6f26076fe 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -12,8 +12,8 @@ import { isFetchError } from '@grafana/runtime'; import { clearFolders } from 'app/features/browse-dashboards/state/slice'; import { getState } from 'app/store/store'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; import { handleError } from '../../../utils'; diff --git a/public/app/api/utils.ts b/public/app/api/utils.ts index 3866bbe977d..9efa6940650 100644 --- a/public/app/api/utils.ts +++ b/public/app/api/utils.ts @@ -1,8 +1,8 @@ import { normalizeError } from '@grafana/api-clients'; import { ThunkDispatch } from 'app/types/store'; -import { notifyApp } from '../core/actions'; import { createErrorNotification } from '../core/copy/appNotification'; +import { notifyApp } from '../core/reducers/appNotification'; /** * Handle an error from a k8s API call diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts deleted file mode 100644 index d73c489b33e..00000000000 --- a/public/app/core/actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { hideAppNotification, notifyApp } from '../reducers/appNotification'; -import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; - -export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 8d5a1d3161e..e194ada32ed 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -4,10 +4,9 @@ import { useLocation } from 'react-router-dom'; import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; -import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { selectVisible } from 'app/core/reducers/appNotification'; +import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification'; import { useSelector, useDispatch } from 'app/types/store'; import { diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 3f1dd8f484f..8dc671d303f 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store'; import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications'; import { useDispatch } from 'app/types/store'; -import { notifyApp } from '../actions'; +import { notifyApp } from '../reducers/appNotification'; const defaultSuccessNotification = { title: '', diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index e94929939f4..f8944dcd7d6 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -10,7 +10,6 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; import { RichHistoryQuery } from 'app/types/explore'; @@ -23,6 +22,7 @@ import { } from '../history/RichHistoryStorage'; import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils'; import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { notifyApp } from '../reducers/appNotification'; import { contextSrv } from '../services/context_srv'; import { diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 208f2ae0fcc..e6e260e6ccc 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -5,7 +5,6 @@ import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; @@ -14,6 +13,7 @@ import { dispatch } from 'app/store/store'; import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { extractErrorMessage } from '../../api/utils'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; +import { notifyApp } from '../reducers/appNotification'; import { copyStringToClipboard } from './explore'; diff --git a/public/app/features/dashboard-scene/pages/utils.ts b/public/app/features/dashboard-scene/pages/utils.ts index 6e786123a87..1c6e36e09f8 100644 --- a/public/app/features/dashboard-scene/pages/utils.ts +++ b/public/app/features/dashboard-scene/pages/utils.ts @@ -1,7 +1,7 @@ import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; -import { updateNavIndex } from 'app/core/actions'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { buildNavModel } from 'app/features/folders/state/navModel'; import { store } from 'app/store/store'; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index fc7039bb075..7d11d1518be 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -9,8 +9,8 @@ import { sceneGraph, SceneTimeRangeLike, } from '@grafana/scenes'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts index 96492361e8d..0878ee35307 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -12,9 +12,9 @@ import { LibraryPanelRef, LibraryPanelKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { notifyApp } from 'app/core/actions'; import config from 'app/core/config'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx index 9ffad2ef066..5673dff8068 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx @@ -9,8 +9,8 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { ShareExportTab } from '../ShareExportTab'; diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index 9c4abbc3a6d..0de2fbed8cf 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv'; import { dispatch } from 'app/store/store'; diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 59e9f81fef0..e6c9e77a852 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -3,8 +3,8 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { createBaseQuery } from '@grafana/api-clients/rtkq'; import { t } from '@grafana/i18n'; import { config, FetchError, isFetchError } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { PublicDashboard, PublicDashboardSettings, diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index be0d9cfcaea..394471cd770 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -14,7 +14,6 @@ import { ToolbarButtonRow, ConfirmModal, } from '@grafana/ui'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; @@ -22,7 +21,7 @@ import config from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useBusEvent } from 'app/core/hooks/useBusEvent'; import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; -import { removeNavIndex } from 'app/core/reducers/navModel'; +import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel'; import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 7363aff85f8..93119592712 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -24,6 +24,7 @@ import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; @@ -32,7 +33,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { StoreState } from 'app/types/store'; -import { notifyApp } from '../../../../core/actions'; import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal'; import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; import { getVariablesByKey } from '../../../variables/state/selectors'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index b1ae5811f24..abd41458b64 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -8,10 +8,10 @@ import { createTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, setDataSourceSrv } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { notifyApp } from 'app/core/actions'; import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps'; import { RouteDescriptor } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 8733fe0311d..4b0b9ed1d3d 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -6,13 +6,13 @@ import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil, Grafan import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { ID_PREFIX } from 'app/core/reducers/navBarTree'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 05c20ee1d9f..40ed686f350 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -1,8 +1,8 @@ import { PanelModel } from '@grafana/data'; import { t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { dispatch } from 'app/types/store'; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 0395c6e9201..3d882c4d4c1 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,8 +1,8 @@ import { TimeZone } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { WeekStart } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { removeAllPanels } from 'app/features/panel/state/reducers'; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 86d64138dee..c2a02bca86b 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,9 +1,9 @@ import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, isFetchError, locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { backendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import store from 'app/core/store'; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 961c78c8208..fa9db9f7abe 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -17,8 +17,8 @@ import { isFetchError, locationService, } from '@grafana/runtime'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { DatasourceAPIVersions } from 'app/features/apiserver/client'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 4c9353254e0..421e4f35294 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -8,8 +8,8 @@ import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { copyStringToClipboard } from 'app/core/utils/explore'; import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; diff --git a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx index 05942398898..696e5d204ae 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx @@ -4,9 +4,9 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; import { useStyles2, Select, Button, Field, InlineField, InlineSwitch, Alert } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; diff --git a/public/app/features/explore/state/correlations.ts b/public/app/features/explore/state/correlations.ts index cc99f6388b7..5adf7868d0b 100644 --- a/public/app/features/explore/state/correlations.ts +++ b/public/app/features/explore/state/correlations.ts @@ -2,8 +2,8 @@ import { Observable } from 'rxjs'; import { DataLinkTransformationConfig } from '@grafana/data'; import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { CreateCorrelationParams } from 'app/features/correlations/types'; import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils'; import { store } from 'app/store/store'; diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 39ad4ea5e73..2f921078135 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -22,6 +22,7 @@ import { import { combinePanelData } from '@grafana/o11y-ds-frontend'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildQueryTransaction, ensureQueries, @@ -48,7 +49,6 @@ import { } from 'app/types/explore'; import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store'; -import { notifyApp } from '../../../core/actions'; import { createErrorNotification } from '../../../core/copy/appNotification'; import { runRequest } from '../../query/state/runRequest'; import { decorateData, decorateWithLogsResult } from '../utils/decorators'; diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index f897c23cfb9..049a3b920cf 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -6,8 +6,8 @@ import { PanelQueryKind, AnnotationQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ThunkResult } from 'app/types/store'; diff --git a/public/app/features/org/state/actions.test.ts b/public/app/features/org/state/actions.test.ts index b624430697a..f3429d8ea0b 100644 --- a/public/app/features/org/state/actions.test.ts +++ b/public/app/features/org/state/actions.test.ts @@ -2,7 +2,7 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { OrgRole } from '@grafana/data'; import { BackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { updateOrganization, setUserOrganization, getUserOrganizations } from './actions'; diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts index 1d580c5b3ae..672c2b2bdac 100644 --- a/public/app/features/org/state/actions.ts +++ b/public/app/features/org/state/actions.ts @@ -1,5 +1,5 @@ import { getBackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { ThunkResult } from 'app/types/store'; import { UserOrg } from 'app/types/user'; diff --git a/public/app/features/teams/hooks.ts b/public/app/features/teams/hooks.ts index 2c563ef3d2f..e0af32b6f3f 100644 --- a/public/app/features/teams/hooks.ts +++ b/public/app/features/teams/hooks.ts @@ -12,8 +12,8 @@ import { useUpdateTeamMutation, UpdateTeamCommand, } from 'app/api/clients/legacy'; -import { updateNavIndex } from 'app/core/actions'; import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction, Role } from 'app/types/accessControl'; import { useDispatch } from 'app/types/store'; diff --git a/public/app/features/theme-playground/ThemePlayground.tsx b/public/app/features/theme-playground/ThemePlayground.tsx index 77df7b5db87..2a8da67b340 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -9,8 +9,8 @@ import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui'; import { ThemeDemo } from '@grafana/ui/internal'; import { Page } from 'app/core/components/Page/Page'; -import { notifyApp } from '../../core/actions'; import { createErrorNotification } from '../../core/copy/appNotification'; +import { notifyApp } from '../../core/reducers/appNotification'; import { HOME_NAV_ID } from '../../core/reducers/navModel'; import { getNavModel } from '../../core/selectors/navModel'; import { ThemeProvider } from '../../core/utils/ConfigProvider'; diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index a16d23b570a..f78ed1ecf41 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,8 +1,8 @@ import { dateTime } from '@grafana/data'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; -import { notifyApp } from '../../../core/actions'; import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { TemplateSrv } from '../../templating/template_srv'; import { variableAdapters } from '../adapters'; diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index a92193a0867..95f73bd610e 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -21,7 +21,7 @@ import { VariableWithOptions, } from '@grafana/data'; import { config, locationService, logWarning } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; diff --git a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts +++ b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/graphite/webpack.config.ts b/public/app/plugins/datasource/graphite/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/graphite/webpack.config.ts +++ b/public/app/plugins/datasource/graphite/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/loki/webpack.config.ts b/public/app/plugins/datasource/loki/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/loki/webpack.config.ts +++ b/public/app/plugins/datasource/loki/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/mysql/webpack.config.ts b/public/app/plugins/datasource/mysql/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/mysql/webpack.config.ts +++ b/public/app/plugins/datasource/mysql/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/opentsdb/webpack.config.ts b/public/app/plugins/datasource/opentsdb/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/opentsdb/webpack.config.ts +++ b/public/app/plugins/datasource/opentsdb/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 6e51e5511b1..9a0abe1cc27 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -61,7 +61,6 @@ describe('Tempo data source', () => { describe('runs correctly', () => { const handleStreamingQuery = jest.spyOn(TempoDatasource.prototype, 'handleStreamingQuery'); - const request = jest.spyOn(TempoDatasource.prototype, '_request'); const templateSrv: TemplateSrv = { replace: (s: string) => s } as unknown as TemplateSrv; const range = { @@ -97,7 +96,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceqlSearch queries when live is enabled', async () => { @@ -105,7 +103,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceql queries when live is not enabled', async () => { @@ -113,7 +110,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); it('for traceqlSearch queries when live is not enabled', async () => { @@ -121,7 +117,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 4c926300cb3..b15c8d6146a 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -23,14 +23,11 @@ import { SelectableValue, TestDataSourceResponse, TimeRange, - urlUtil, } from '@grafana/data'; import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'; import { - BackendSrvRequest, config, DataSourceWithBackend, - getBackendSrv, getDataSourceSrv, getTemplateSrv, reportInteraction, @@ -59,7 +56,6 @@ import { import TempoLanguageProvider from './language_provider'; import { enhanceTraceQlMetricsResponse, - formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace, } from './resultTransformer'; @@ -419,12 +415,7 @@ export class TempoDatasource extends DataSourceWithBackend, - targets: { [type: string]: TempoQuery[] }, - queryValue: string - ) => { - const startTime = performance.now(); - const tableType = targets.traceqlSearch?.[0]?.tableType ?? targets.traceql?.[0]?.tableType; - - return this._request('/api/search', { - q: queryValue, - limit: options.targets[0].limit ?? DEFAULT_LIMIT, - spss: options.targets[0].spss ?? DEFAULT_SPSS, - start: options.range.from.unix(), - end: options.range.to.unix(), - }).pipe( - map((response) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: true, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - }); - return { - data: formatTraceQLResponse(response.data.traces, this.instanceSettings, tableType), - }; - }), - catchError((err) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: false, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - error: getErrorMessage(err.message), - statusCode: err.status, - statusText: err.statusText, - }); - return of({ error: { message: getErrorMessage(err?.data?.message) }, data: [] }); - }) - ); - }; - handleTraceQlMetricsQuery( options: DataQueryRequest, targets: TempoQuery[], @@ -926,13 +870,6 @@ export class TempoDatasource extends DataSourceWithBackend): Observable> { - const params = data ? urlUtil.serializeParams(data) : ''; - const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`; - const req = { ...options, url }; - return getBackendSrv().fetch(req); - } - async testDatasource(): Promise { return await super.testDatasource(); } diff --git a/public/app/plugins/datasource/tempo/webpack.config.ts b/public/app/plugins/datasource/tempo/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/tempo/webpack.config.ts +++ b/public/app/plugins/datasource/tempo/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/zipkin/webpack.config.ts b/public/app/plugins/datasource/zipkin/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/zipkin/webpack.config.ts +++ b/public/app/plugins/datasource/zipkin/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config;