From 81b868ae9121796300aff91ed35a30d3139f1be8 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Mon, 12 Jan 2026 09:00:51 +0100 Subject: [PATCH 1/9] `grafana-iam`: Split AuthZ apis feature toggle per apis (#116010) * WIP: switched to feature toggles * Add timeout --- .../src/types/featureToggles.gen.ts | 15 +++++- pkg/registry/apis/iam/register.go | 30 ++++++++++-- pkg/services/featuremgmt/registry.go | 25 +++++++++- pkg/services/featuremgmt/toggles_gen.csv | 5 +- pkg/services/featuremgmt/toggles_gen.go | 14 +++++- pkg/services/featuremgmt/toggles_gen.json | 48 +++++++++++++++++-- 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6e35e460055..5b24184ff6c 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; 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/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 885079a5f5a..b38d3d3d553 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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8ddc448ef52..c7626aee036 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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c42229d8870..49f0366f429 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" 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", From 0b4612330082cb4ee93cd9382149ec69c91c170f Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 12 Jan 2026 17:21:48 +0900 Subject: [PATCH 2/9] Tempo: remove backend migration feature toggle (#116054) * remove unused frontend code * remove feature toggle definition * fix tests --- .../feature-toggles/index.md | 1 - eslint-suppressions.json | 2 +- .../src/types/featureToggles.gen.ts | 5 -- pkg/services/featuremgmt/registry.go | 8 --- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 -- .../datasource/tempo/datasource.test.ts | 5 -- .../plugins/datasource/tempo/datasource.ts | 67 +------------------ 8 files changed, 3 insertions(+), 90 deletions(-) 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..6d5cca4f36c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4098,7 +4098,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "public/app/plugins/datasource/tempo/resultTransformer.ts": { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5b24184ff6c..3982bdc33e5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1137,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/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b38d3d3d553..ed591908042 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1880,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 c7626aee036..15376f39fc1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -256,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 49f0366f429..e778a6fc3d1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -742,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/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(); } From f0c95a0a10b7ee9e298d6c6443ee9f72e85ccf25 Mon Sep 17 00:00:00 2001 From: Gonzalo Trigueros Manzanas <242162051+gttrigger@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:07:04 +0100 Subject: [PATCH 3/9] Provisioning: Add new error framework to handle folder creation failures gracefully. (#114824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement hierarchical error handling for folder creation failures This commit implements hierarchical error handling to improve sync robustness when folder creation fails. Instead of failing the entire sync, the system now: 1. Tracks failed folder creations and automatically skips nested resources 2. Records skipped resources with FileActionIgnored (doesn't count toward error limits) 3. Allows other folder hierarchies to continue processing 4. Prevents folder deletion when child resource deletions fail Key Changes: - Add PathCreationError type to track which folder path failed - Modify progress recorder to automatically detect and track failures via Record() - Add IsNestedUnderFailedCreation() and HasFailedDeletionsUnder() checks - Update full and incremental sync to skip nested resources after folder failures - Deletions proceed even if parent folder creation failed (resource may exist from previous sync) - FileActionIgnored results don't count toward error limits Example behavior improvement: Before: /monitoring folder creation fails → all nested resources fail → other folders never processed After: /monitoring folder creation fails → nested resources ignored → /applications folder succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * provisioning: refactor hierarchical errors in folder management. * Move test to the corresponding package * Refactor timeout handling in applyChanges functions - Introduced wrapWithTimeout function to streamline timeout context management for applyChange calls. - Updated applyFoldersSerially and applyIncrementalChanges to utilize the new timeout wrapper. - Removed redundant logging and error handling code related to timeout in favor of centralized handling in wrapWithTimeout. - Adjusted test expectations to reflect changes in error reporting for context deadlines. --------- Co-authored-by: Roberto Jimenez Sanchez Co-authored-by: Claude Sonnet 4.5 --- .../jobs/job_progress_recorder_mock.go | 92 +++ .../apis/provisioning/jobs/progress.go | 55 +- .../apis/provisioning/jobs/progress_test.go | 219 ++++++ pkg/registry/apis/provisioning/jobs/queue.go | 4 + .../apis/provisioning/jobs/sync/full.go | 82 ++- .../jobs/sync/full_hierarchical_test.go | 432 ++++++++++++ .../apis/provisioning/jobs/sync/full_test.go | 21 +- .../provisioning/jobs/sync/incremental.go | 33 +- .../sync/incremental_hierarchical_test.go | 623 ++++++++++++++++++ .../jobs/sync/incremental_test.go | 54 +- .../apis/provisioning/resources/folders.go | 21 +- .../provisioning/resources/folders_test.go | 68 ++ 12 files changed, 1651 insertions(+), 53 deletions(-) create mode 100644 pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go create mode 100644 pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go create mode 100644 pkg/registry/apis/provisioning/resources/folders_test.go 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/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) + }) +} From e0ad4eb7ed5030e75970da5c15e1299491a8c023 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 12 Jan 2026 10:17:02 +0100 Subject: [PATCH 4/9] Chore: Remove core actions barrel file (#98149) * refactor(frontend): update core/actions imports to avoid barrel file * chore(frontend): delete app/core/actions barrel file * refactor(frontend): replace more barrel file imports * refactor(frontend): replace more core/actions imports * rerun ci --- eslint-suppressions.json | 5 ----- public/app/api/clients/collections/v1alpha1/index.ts | 2 +- public/app/api/clients/playlist/v0alpha1/index.ts | 2 +- public/app/api/clients/provisioning/v0alpha1/index.ts | 2 +- public/app/api/utils.ts | 2 +- public/app/core/actions/index.ts | 4 ---- .../core/components/AppNotifications/AppNotificationList.tsx | 3 +-- public/app/core/copy/appNotification.ts | 2 +- public/app/core/utils/richHistory.ts | 2 +- public/app/core/utils/shortLinks.ts | 2 +- public/app/features/dashboard-scene/pages/utils.ts | 2 +- .../features/dashboard-scene/scene/AlertStatesDataLayer.ts | 2 +- .../app/features/dashboard-scene/scene/export/exporters.ts | 2 +- .../dashboard-scene/sharing/ExportButton/ExportAsCode.tsx | 2 +- .../features/dashboard-scene/sharing/ShareSnapshotTab.tsx | 2 +- public/app/features/dashboard/api/publicDashboardApi.ts | 2 +- public/app/features/dashboard/components/DashNav/DashNav.tsx | 3 +-- .../dashboard/components/PanelEditor/PanelEditor.tsx | 2 +- .../app/features/dashboard/containers/DashboardPage.test.tsx | 2 +- public/app/features/dashboard/containers/DashboardPage.tsx | 2 +- .../DashboardLibrary/utils/communityDashboardHelpers.ts | 2 +- public/app/features/dashboard/state/actions.ts | 2 +- public/app/features/dashboard/state/initDashboard.ts | 2 +- public/app/features/datasources/state/actions.ts | 2 +- public/app/features/explore/RichHistory/RichHistoryCard.tsx | 2 +- .../features/explore/RichHistory/RichHistorySettingsTab.tsx | 2 +- public/app/features/explore/state/correlations.ts | 2 +- public/app/features/explore/state/query.ts | 2 +- public/app/features/manage-dashboards/state/actions.ts | 2 +- public/app/features/org/state/actions.test.ts | 2 +- public/app/features/org/state/actions.ts | 2 +- public/app/features/teams/hooks.ts | 2 +- public/app/features/theme-playground/ThemePlayground.tsx | 2 +- public/app/features/variables/interval/actions.test.ts | 2 +- public/app/features/variables/state/actions.ts | 2 +- 35 files changed, 33 insertions(+), 44 deletions(-) delete mode 100644 public/app/core/actions/index.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 6d5cca4f36c..c92d2939837 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 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'; From 86a3aae20419092be99d99caa0d7428eeab11887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Tolnai?= Date: Mon, 12 Jan 2026 10:30:38 +0100 Subject: [PATCH 5/9] InteractiveTable: Extend sort options with `disableSortRemove` and `sortDescFirst` (#115352) * add disableSortRemove option * add sortDescFirst to Column * pass sortDescFirst only if it is set --- .../src/components/InteractiveTable/InteractiveTable.tsx | 6 ++++++ .../grafana-ui/src/components/InteractiveTable/types.ts | 4 ++++ .../grafana-ui/src/components/InteractiveTable/utils.ts | 1 + 3 files changed, 11 insertions(+) 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 }), })), ]; From e4796b1de3589bc734960666335948af4b5f5631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Mon, 12 Jan 2026 10:31:25 +0100 Subject: [PATCH 6/9] Provisioning: Add fieldSelector for Repository by spec.connection.name (#116063) * Provisioning: Add fieldSelector for Repository by spec.connection.name This change adds the ability to filter repositories by their connection name using Kubernetes field selectors, enabling queries like: kubectl get repositories --field-selector spec.connection.name=my-connection Implementation: - Add RepositoryGetAttrs and RepositoryToSelectableFields functions - Register field label conversion for spec.connection.name in InstallSchema - Extend generic storage to support custom selectable fields via NewRegistryStoreWithSelectableFields - Add unit tests for repository field functions - Add integration tests for field selector functionality * Simplify predicateFunc handling with custom attrFunc Remove unnecessary custom predicateFunc wrapper when using a custom GetAttrs function. When attrFunc is provided via StoreOptions, passing nil for predicateFunc allows the default behavior to create the appropriate SelectionPredicate automatically. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- pkg/apiserver/registry/generic/storage.go | 33 +++- pkg/registry/apis/provisioning/register.go | 27 ++- .../apis/provisioning/repository_fields.go | 44 +++++ .../provisioning/repository_fields_test.go | 184 ++++++++++++++++++ .../apis/provisioning/connection_test.go | 172 ++++++++++++++++ 5 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 pkg/registry/apis/provisioning/repository_fields.go create mode 100644 pkg/registry/apis/provisioning/repository_fields_test.go 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/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/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") + }) +} From a0e894c6d8858e8ae0a87bfd67c831b60255fedb Mon Sep 17 00:00:00 2001 From: james-rms Date: Mon, 12 Jan 2026 20:57:06 +1100 Subject: [PATCH 7/9] Documentation: Fix typo in plugin-sign.md heading (#115812) --- docs/sources/administration/plugin-management/plugin-sign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 586410d8b5511cde2efad8bca07cd1d9d670aca5 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 12 Jan 2026 11:12:40 +0100 Subject: [PATCH 8/9] Build: Fix running e2e tests for Cypress with Dagger (#116105) --- pkg/build/e2e/README.md | 20 ++++++++++++++++++++ pkg/build/e2e/main.go | 4 ++++ pkg/build/e2e/run.go | 4 ++-- pkg/build/e2e/service.go | 14 ++++++++------ 4 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 pkg/build/e2e/README.md 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. From 5cb4c311dc65c9b42fcc0fd14d1183a097aeba46 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 12 Jan 2026 12:13:11 +0100 Subject: [PATCH 9/9] Chore: Eslint ignore webpack.config barrel files (#116115) chore(eslint): ignore decoupled plugins webpack configs barrel files --- eslint-suppressions.json | 5 ----- eslint.config.js | 2 ++ .../plugins/datasource/cloud-monitoring/webpack.config.ts | 1 - .../grafana-postgresql-datasource/webpack.config.ts | 1 - .../datasource/grafana-testdata-datasource/webpack.config.ts | 1 - public/app/plugins/datasource/graphite/webpack.config.ts | 1 - public/app/plugins/datasource/loki/webpack.config.ts | 1 - public/app/plugins/datasource/mysql/webpack.config.ts | 1 - public/app/plugins/datasource/opentsdb/webpack.config.ts | 1 - public/app/plugins/datasource/tempo/webpack.config.ts | 1 - public/app/plugins/datasource/zipkin/webpack.config.ts | 1 - 11 files changed, 2 insertions(+), 14 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c92d2939837..250dbd20348 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4020,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 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/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/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;