From 9fb61bd9f6fc82d14f2b54107338b3888ea1421c Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 16 Dec 2025 08:22:19 +0300 Subject: [PATCH 01/21] Live: more cleanup (#115144) --- pkg/api/dashboard_test.go | 10 +- pkg/server/wire.go | 1 + pkg/server/wire_gen.go | 8 +- pkg/services/dashboards/dashboard.go | 5 + .../dashboards/dashboard_service_mock.go | 8 +- .../dashboards/service/dashboard_service.go | 33 ++++++ pkg/services/dashboards/service/service.go | 6 + pkg/services/live/features/dashboard.go | 63 ++++------- pkg/services/live/live.go | 103 ++---------------- pkg/services/live/live_test.go | 9 +- 10 files changed, 95 insertions(+), 151 deletions(-) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index e33f0f5fdaa..7a667ee5e62 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -112,17 +112,15 @@ func TestGetHomeDashboard(t *testing.T) { } func newTestLive(t *testing.T) *live.GrafanaLive { - features := featuremgmt.WithFeatures() cfg := setting.NewCfg() cfg.AppURL = "http://localhost:3000/" - gLive, err := live.ProvideService(nil, cfg, + gLive, err := live.ProvideService(cfg, routing.NewRouteRegister(), nil, nil, nil, nil, - nil, &usagestats.UsageStatsMock{T: t}, - features, acimpl.ProvideAccessControl(features), - &dashboards.FakeDashboardService{}, - nil, nil) + featuremgmt.WithFeatures(), + &dashboards.FakeDashboardService{}, nil) + require.NoError(t, err) return gLive } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 970d1c003c9..9864bed4e12 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -349,6 +349,7 @@ var wireBasicSet = wire.NewSet( dashboardservice.ProvideDashboardService, dashboardservice.ProvideDashboardProvisioningService, dashboardservice.ProvideDashboardPluginService, + dashboardservice.ProvideDashboardAccessService, dashboardstore.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 5abda77524a..cdc3371db11 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -673,7 +673,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api starService := starimpl.ProvideService(sqlStore) searchSearchService := search2.ProvideService(cfg, sqlStore, starService, dashboardService, folderimplService, featureToggles, sortService) plugincontextProvider := plugincontext.ProvideService(cfg, cacheService, pluginstoreService, cacheServiceImpl, service15, service13, requestConfigProvider) - grafanaLive, err := live.ProvideService(plugincontextProvider, cfg, routeRegisterImpl, pluginstoreService, middlewareHandler, cacheService, cacheServiceImpl, secretsService, usageStats, featureToggles, accessControl, dashboardService, orgService, eventualRestConfigProvider) + dashboardAccessService := service7.ProvideDashboardAccessService(featureToggles, dashboardServiceImpl) + grafanaLive, err := live.ProvideService(cfg, routeRegisterImpl, plugincontextProvider, pluginstoreService, middlewareHandler, cacheServiceImpl, usageStats, featureToggles, dashboardAccessService, eventualRestConfigProvider) if err != nil { return nil, err } @@ -1332,7 +1333,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac starService := starimpl.ProvideService(sqlStore) searchSearchService := search2.ProvideService(cfg, sqlStore, starService, dashboardService, folderimplService, featureToggles, sortService) plugincontextProvider := plugincontext.ProvideService(cfg, cacheService, pluginstoreService, cacheServiceImpl, service15, service13, requestConfigProvider) - grafanaLive, err := live.ProvideService(plugincontextProvider, cfg, routeRegisterImpl, pluginstoreService, middlewareHandler, cacheService, cacheServiceImpl, secretsService, usageStats, featureToggles, accessControl, dashboardService, orgService, eventualRestConfigProvider) + dashboardAccessService := service7.ProvideDashboardAccessService(featureToggles, dashboardServiceImpl) + grafanaLive, err := live.ProvideService(cfg, routeRegisterImpl, plugincontextProvider, pluginstoreService, middlewareHandler, cacheServiceImpl, usageStats, featureToggles, dashboardAccessService, eventualRestConfigProvider) if err != nil { return nil, err } @@ -1798,7 +1800,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 0334cfc8990..fd30728aa35 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -44,6 +44,11 @@ type DashboardService interface { GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error) } +type DashboardAccessService interface { + // The user as access to {VERB} the requested dashboard + HasDashboardAccess(ctx context.Context, user identity.Requester, verb string, namespace string, name string) (bool, error) +} + type PermissionsRegistrationService interface { RegisterDashboardPermissions(service accesscontrol.DashboardPermissionsService) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index d20a9525622..f5ba0e096dc 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -5,9 +5,10 @@ package dashboards import ( context "context" - identity "github.com/grafana/grafana/pkg/apimachinery/identity" mock "github.com/stretchr/testify/mock" + identity "github.com/grafana/grafana/pkg/apimachinery/identity" + model "github.com/grafana/grafana/pkg/services/search/model" unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -529,6 +530,11 @@ func (_m *FakeDashboardService) ValidateDashboardRefreshInterval(minRefreshInter return r0 } +// CanViewDashboard uses the access control service to check if the requested user can see a dashboard +func (_m *FakeDashboardService) HasDashboardAccess(ctx context.Context, user identity.Requester, verb string, namespace string, name string) (bool, error) { + return true, nil +} + // NewFakeDashboardService creates a new instance of FakeDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewFakeDashboardService(t interface { diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 03dd021a480..e105aaa3325 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -67,6 +67,7 @@ var ( _ dashboards.DashboardService = (*DashboardServiceImpl)(nil) _ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil) _ dashboards.PluginService = (*DashboardServiceImpl)(nil) + _ dashboards.DashboardAccessService = (*DashboardServiceImpl)(nil) daysInTrash = 24 * 30 * time.Hour tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboards/service") @@ -100,6 +101,38 @@ type DashboardServiceImpl struct { dashboardPermissionsReady chan struct{} } +// CanViewDashboard uses the access control service to check if the requested user can see a dashboard +func (dr *DashboardServiceImpl) HasDashboardAccess(ctx context.Context, user identity.Requester, verb string, namespace string, name string) (bool, error) { + ns, err := claims.ParseNamespace(namespace) + if err != nil { + return false, err + } + dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{ + UID: name, + OrgID: ns.OrgID, + }) + if err != nil || dash == nil { + return false, nil + } + var action string + switch verb { + case utils.VerbGet: + action = dashboards.ActionDashboardsRead + case utils.VerbUpdate: + action = dashboards.ActionDashboardsWrite + default: + return false, fmt.Errorf("unsupported verb") + } + + evaluator := accesscontrol.EvalPermission(action, + dashboards.ScopeDashboardsProvider.GetResourceScopeUID(name)) + canView, err := dr.ac.Evaluate(ctx, user, evaluator) + if err != nil || !canView { + return false, nil + } + return true, nil +} + func (dr *DashboardServiceImpl) startK8sDeletedDashboardsCleanupJob(ctx context.Context) chan struct{} { done := make(chan struct{}) go func() { diff --git a/pkg/services/dashboards/service/service.go b/pkg/services/dashboards/service/service.go index f526404dc0e..f56d070b695 100644 --- a/pkg/services/dashboards/service/service.go +++ b/pkg/services/dashboards/service/service.go @@ -23,3 +23,9 @@ func ProvideDashboardPluginService( ) dashboards.PluginService { return orig } + +func ProvideDashboardAccessService( + features featuremgmt.FeatureToggles, orig *DashboardServiceImpl, +) dashboards.DashboardAccessService { + return orig +} diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go index 5cf43bafcf7..537042d2da0 100644 --- a/pkg/services/live/features/dashboard.go +++ b/pkg/services/live/features/dashboard.go @@ -6,10 +6,11 @@ import ( "fmt" "strings" + "github.com/grafana/authlib/types" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/live/model" ) @@ -32,10 +33,9 @@ type dashboardEvent struct { // DashboardHandler manages all the `grafana/dashboard/*` channels type DashboardHandler struct { - Publisher model.ChannelPublisher - ClientCount model.ChannelClientCount - DashboardService dashboards.DashboardService - AccessControl accesscontrol.AccessControl + Publisher model.ChannelPublisher + ClientCount model.ChannelClientCount + AccessControl dashboards.DashboardAccessService } // GetHandlerForPath called on init @@ -49,23 +49,15 @@ func (h *DashboardHandler) OnSubscribe(ctx context.Context, user identity.Reques // make sure can view this dashboard if len(parts) == 2 && parts[0] == "uid" { - query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: user.GetOrgID()} - _, err := h.DashboardService.GetDashboard(ctx, &query) - if err != nil { - logger.Error("Error getting dashboard", "query", query, "error", err) - return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil + ns := types.OrgNamespaceFormatter(user.GetOrgID()) + ok, err := h.AccessControl.HasDashboardAccess(ctx, user, utils.VerbGet, ns, parts[1]) + if ok && err == nil { + return model.SubscribeReply{ + Presence: true, + JoinLeave: true, + }, backend.SubscribeStreamStatusOK, nil } - - evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1])) - canView, err := h.AccessControl.Evaluate(ctx, user, evaluator) - if err != nil || !canView { - return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err - } - - return model.SubscribeReply{ - Presence: true, - JoinLeave: true, - }, backend.SubscribeStreamStatusOK, nil + return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err } // Unknown path @@ -88,29 +80,16 @@ func (h *DashboardHandler) OnPublish(ctx context.Context, requester identity.Req // just ignore the event return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("ignore???") } - query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: requester.GetOrgID()} - _, err = h.DashboardService.GetDashboard(ctx, &query) - if err != nil { - logger.Error("Unknown dashboard", "query", query) - return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil - } - evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1])) - canEdit, err := h.AccessControl.Evaluate(ctx, requester, evaluator) - if err != nil { - return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") + ns := types.OrgNamespaceFormatter(requester.GetOrgID()) + ok, err := h.AccessControl.HasDashboardAccess(ctx, requester, utils.VerbUpdate, ns, parts[1]) + if ok && err == nil { + msg, err := json.Marshal(event) + if err != nil { + return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") + } + return model.PublishReply{Data: msg}, backend.PublishStreamStatusOK, nil } - - // Ignore edit events if the user can not edit - if !canEdit { - return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil // NOOP - } - - msg, err := json.Marshal(event) - if err != nil { - return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") - } - return model.PublishReply{Data: msg}, backend.PublishStreamStatusOK, nil } return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 675782ac830..7dbca506e2c 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -27,13 +27,11 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware/requestmeta" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -52,7 +50,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -72,28 +69,23 @@ type CoreGrafanaScope struct { Dashboards DashboardActivityChannel } -func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, routeRegister routing.RouteRegister, - pluginStore pluginstore.Store, pluginClient plugins.Client, cacheService *localcache.CacheService, - dataSourceCache datasources.CacheService, secretsService secrets.Service, +func ProvideService(cfg *setting.Cfg, routeRegister routing.RouteRegister, plugCtxProvider *plugincontext.Provider, + pluginStore pluginstore.Store, pluginClient plugins.Client, dataSourceCache datasources.CacheService, usageStatsService usagestats.Service, toggles featuremgmt.FeatureToggles, - accessControl accesscontrol.AccessControl, dashboardService dashboards.DashboardService, - orgService org.Service, configProvider apiserver.RestConfigProvider) (*GrafanaLive, error) { + dashboardService dashboards.DashboardAccessService, + configProvider apiserver.RestConfigProvider) (*GrafanaLive, error) { g := &GrafanaLive{ Cfg: cfg, Features: toggles, PluginContextProvider: plugCtxProvider, - RouteRegister: routeRegister, pluginStore: pluginStore, pluginClient: pluginClient, - CacheService: cacheService, DataSourceCache: dataSourceCache, - SecretsService: secretsService, channels: make(map[string]model.ChannelHandler), GrafanaScope: CoreGrafanaScope{ Features: make(map[string]model.ChannelHandlerFactory), }, usageStatsService: usageStatsService, - orgService: orgService, keyPrefix: "gf_live", } @@ -176,19 +168,13 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r // Initialize the main features dash := &features.DashboardHandler{ - Publisher: g.Publish, - ClientCount: g.ClientCount, - DashboardService: dashboardService, - AccessControl: accessControl, + Publisher: g.Publish, + ClientCount: g.ClientCount, + AccessControl: dashboardService, } g.GrafanaScope.Dashboards = dash g.GrafanaScope.Features["dashboard"] = dash - - // Testing watch with just the provisioning support -- this will be removed when it is well validated - //nolint:staticcheck // not yet migrated to OpenFeature - if toggles.IsEnabledGlobally(featuremgmt.FlagProvisioning) { - g.GrafanaScope.Features["watch"] = features.NewWatchRunner(g.Publish, configProvider) - } + g.GrafanaScope.Features["watch"] = features.NewWatchRunner(g.Publish, configProvider) g.surveyCaller = survey.NewCaller(managedStreamRunner, node) err = g.surveyCaller.SetupHandlers() @@ -398,11 +384,11 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r pushPipelineWSHandler.ServeHTTP(ctx.Resp, r) } - g.RouteRegister.Group("/api/live", func(group routing.RouteRegister) { + routeRegister.Group("/api/live", func(group routing.RouteRegister) { group.Get("/ws", g.websocketHandler) }, middleware.ReqSignedIn, requestmeta.SetSLOGroup(requestmeta.SLOGroupNone)) - g.RouteRegister.Group("/api/live", func(group routing.RouteRegister) { + routeRegister.Group("/api/live", func(group routing.RouteRegister) { group.Get("/push/:streamId", g.pushWebsocketHandler) group.Get("/pipeline/push/*", g.pushPipelineWebsocketHandler) }, middleware.ReqOrgAdmin, requestmeta.SetSLOGroup(requestmeta.SLOGroupNone)) @@ -461,13 +447,9 @@ type GrafanaLive struct { PluginContextProvider *plugincontext.Provider Cfg *setting.Cfg Features featuremgmt.FeatureToggles - RouteRegister routing.RouteRegister - CacheService *localcache.CacheService DataSourceCache datasources.CacheService - SecretsService secrets.Service pluginStore pluginstore.Store pluginClient plugins.Client - orgService org.Service keyPrefix string // HA prefix for grafana cloud (since the org is always 1) @@ -1356,71 +1338,6 @@ func (g *GrafanaLive) HandleWriteConfigsPostHTTP(c *contextmodel.ReqContext) res }) } -// HandleWriteConfigsPutHTTP ... -func (g *GrafanaLive) HandleWriteConfigsPutHTTP(c *contextmodel.ReqContext) response.Response { - body, err := io.ReadAll(c.Req.Body) - if err != nil { - return response.Error(http.StatusInternalServerError, "Error reading body", err) - } - var cmd pipeline.WriteConfigUpdateCmd - err = json.Unmarshal(body, &cmd) - if err != nil { - return response.Error(http.StatusBadRequest, "Error decoding write config update command", err) - } - if cmd.UID == "" { - return response.Error(http.StatusBadRequest, "UID required", nil) - } - existingBackend, ok, err := g.pipelineStorage.GetWriteConfig(c.Req.Context(), c.GetOrgID(), pipeline.WriteConfigGetCmd{ - UID: cmd.UID, - }) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to get write config", err) - } - if ok { - if cmd.SecureSettings == nil { - cmd.SecureSettings = map[string]string{} - } - secureJSONData, err := g.SecretsService.DecryptJsonData(c.Req.Context(), existingBackend.SecureSettings) - if err != nil { - logger.Error("Error decrypting secure settings", "error", err) - return response.Error(http.StatusInternalServerError, "Error decrypting secure settings", err) - } - for k, v := range secureJSONData { - if _, ok := cmd.SecureSettings[k]; !ok { - cmd.SecureSettings[k] = v - } - } - } - result, err := g.pipelineStorage.UpdateWriteConfig(c.Req.Context(), c.GetOrgID(), cmd) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to update write config", err) - } - return response.JSON(http.StatusOK, util.DynMap{ - "writeConfig": pipeline.WriteConfigToDto(result), - }) -} - -// HandleWriteConfigsDeleteHTTP ... -func (g *GrafanaLive) HandleWriteConfigsDeleteHTTP(c *contextmodel.ReqContext) response.Response { - body, err := io.ReadAll(c.Req.Body) - if err != nil { - return response.Error(http.StatusInternalServerError, "Error reading body", err) - } - var cmd pipeline.WriteConfigDeleteCmd - err = json.Unmarshal(body, &cmd) - if err != nil { - return response.Error(http.StatusBadRequest, "Error decoding write config delete command", err) - } - if cmd.UID == "" { - return response.Error(http.StatusBadRequest, "UID required", nil) - } - err = g.pipelineStorage.DeleteWriteConfig(c.Req.Context(), c.GetOrgID(), cmd) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to delete write config", err) - } - return response.JSON(http.StatusOK, util.DynMap{}) -} - // Write to the standard log15 logger func handleLog(msg centrifuge.LogEntry) { arr := make([]interface{}, 0) diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index d3dcf378521..9412f32c6f8 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" @@ -340,16 +339,14 @@ func setupLiveService(cfg *setting.Cfg, t *testing.T) (*GrafanaLive, error) { cfg = setting.NewCfg() } - return ProvideService(nil, - cfg, + return ProvideService(cfg, routing.NewRouteRegister(), - nil, nil, nil, nil, + nil, nil, nil, nil, &usagestats.UsageStatsMock{T: t}, featuremgmt.WithFeatures(), - acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), &dashboards.FakeDashboardService{}, - nil, nil) + nil) } type dummyTransport struct { From 2d6c1c4e9ea68253b593b785915ca22c9395a55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:00:22 +0100 Subject: [PATCH 02/21] docs: add readme for unified storage on-prem migrations (#114397) * docs: add documentation for unified storage migrations * docs: move * docs: rename title * docs: add docs * fix: update table * fix: lint * docs: add migration table explanation --- pkg/storage/unified/README.md | 30 ++++++ pkg/storage/unified/migrations/README.md | 122 +++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 pkg/storage/unified/migrations/README.md diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index e8cf6598d19..e9bdbf88e37 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -1346,4 +1346,34 @@ Key metrics for monitoring Unified Search: - `unified_search_shadow_requests_total`: Shadow traffic request counts - `unified_search_ring_members`: Number of active search server instances +## Data migrations +Unified storage includes an automated migration system that transfers resources from legacy SQL tables to unified storage. Migrations run automatically during Grafana startup when enabled. + +### Supported resources + +- Folders +- Dashboards +- Library panels +- Playlists + +### Validation + +Built-in validators ensure data integrity after migration: + +- **CountValidator**: Verifies resource counts match between legacy and unified storage +- **FolderTreeValidator**: Validates folder parent-child relationships are preserved + +### Configuration + +Enable migrations in `grafana.ini`: + +```ini +[unified_storage] +disable_data_migrations = false +``` + +### Documentation + +For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md). + \ No newline at end of file diff --git a/pkg/storage/unified/migrations/README.md b/pkg/storage/unified/migrations/README.md new file mode 100644 index 00000000000..b0c84d81678 --- /dev/null +++ b/pkg/storage/unified/migrations/README.md @@ -0,0 +1,122 @@ +# Unified storage data migrations + +Automated migration system for moving Grafana resources from legacy SQL storage to unified storage. + +## Overview + +The migration system transfers resources from legacy SQL tables to Grafana's unified storage backend. It runs automatically during Grafana startup and validates data integrity after each migration. + +### Supported resources + +| Resource | API Group | Legacy table | +|----------|-----------|--------------| +| Folders | `folder.grafana.app` | `dashboard` | +| Dashboards | `dashboard.grafana.app` | `dashboard` | +| Library panels | `dashboard.grafana.app` | `library_element` | +| Playlists | `playlist.grafana.app` | `playlist` | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ResourceMigration │ +│ (Orchestrates per-organization migration) │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + UnifiedMigrator Validators BulkProcess API + (Stream legacy (Validate after (Write to unified + resources) migration) storage) +``` + +### Components + +- **`service.go`**: Migration service entry point and registration +- **`migrator.go`**: Core migration logic using streaming BulkProcess API +- **`resource_migration.go`**: Per-organization migration execution +- **`validator.go`**: Post-migration validation (CountValidator, FolderTreeValidator) +- **`resources.go`**: Registry of migratable resource types + +## How migrations work + +### Migration flow + +1. Grafana starts and checks migration status in `unifiedstorage_migration_log` table +2. For each organization, the migrator: + - Reads resources from legacy SQL tables + - Streams resources to unified storage via BulkProcess API + - Runs validators to verify data integrity +3. Records migration result in `unifiedstorage_migration_log` table + +### Per-organization execution + +Migrations run independently for each organization using namespace format `org-{orgId}`. + +## Validators + +### CountValidator + +Compares resource counts between legacy SQL and unified storage. Accounts for rejected items during validation. + +### FolderTreeValidator + +Verifies folder parent-child relationships are preserved after migration. + +## Configuration + +To enable migrations, set the following in your Grafana configuration: + +```ini +[unified_storage] +disable_data_migrations = false +``` + +## Monitoring + +### Log messages + +Successful migration: + +``` +info: storage.unified.resource_migration Starting migration for all organizations +info: storage.unified.resource_migration Migration completed successfully for all organizations +``` + +Failed migration: + +``` +error: storage.unified.resource_migration Migration validation failed +``` + +### Migration status + +Query the migration log table to check status: + +```sql +SELECT * FROM unifiedstorage_migration_log WHERE migration_id LIKE '%folders-dashboards%'; +``` + +The `migration_id` is defined in `service.go` during registration. Ideally, it should be the resource type(s) being migrated. + +## Development + +### Adding a new validator + +Implement the `Validator` interface: + +```go +type Validator interface { + Name() string + Validate(ctx context.Context, sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error +} +``` + +Register the validator in `service.go` when creating the `ResourceMigration`. + +### Adding a new resource type + +1. Add the resource definition to `registeredResources` in `resources.go` +2. Implement the migrator function in the `MigrationDashboardAccessor` interface +3. Register the migration in `service.go` + From 6350b26326e6b6bbc7b9d8b538f6a06572239363 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 16 Dec 2025 09:37:59 +0100 Subject: [PATCH 03/21] Fix: Move the hidden users exclusion to the DB layer (#115254) * Move the hidden users exclusion to the store layer * Address Copilot's feedback * Improve test case name --- pkg/api/org_users.go | 4 +- pkg/api/org_users_test.go | 19 +++- pkg/services/org/model.go | 2 + pkg/services/org/orgimpl/org.go | 1 + pkg/services/org/orgimpl/store.go | 31 +++++++ pkg/services/org/orgimpl/store_test.go | 115 +++++++++++++++++++++++-- 6 files changed, 162 insertions(+), 10 deletions(-) diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 8a10cc24944..37b459f0e69 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -294,6 +294,7 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *contextmodel.ReqContext) respo } func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { + query.ExcludeHiddenUsers = true result, err := hs.orgService.SearchOrgUsers(c.Req.Context(), query) if err != nil { return nil, err @@ -303,9 +304,6 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or userIDs := map[string]bool{} authLabelsUserIDs := make([]int64, 0, len(result.OrgUsers)) for _, user := range result.OrgUsers { - if dtos.IsHiddenUser(user.Login, c.SignedInUser, hs.Cfg) { - continue - } user.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, user.Email) userIDs[fmt.Sprint(user.UserID)] = true diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index a43b5c7edcf..c8313ecefce 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -171,11 +171,16 @@ func TestIntegrationOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{ OrgUsers: []*org.OrgUserDTO{ {Login: testUserLogin, Email: "testUser@grafana.com"}, - {Login: "user1", Email: "user1@grafana.com"}, {Login: "user2", Email: "user2@grafana.com"}, }, } + orgService.SearchOrgUsersFn = func(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { + require.True(t, query.ExcludeHiddenUsers) + return orgService.ExpectedSearchOrgUsersResult, nil + } + defer func() { orgService.SearchOrgUsersFn = nil }() + sc.handlerFunc = hs.GetOrgUsersForCurrentOrg sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -191,6 +196,18 @@ func TestIntegrationOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling GET as an admin on", "GET", "api/org/users/lookup", "api/org/users/lookup", org.RoleAdmin, func(sc *scenarioContext) { + orgService.ExpectedSearchOrgUsersResult = &org.SearchOrgUsersQueryResult{ + OrgUsers: []*org.OrgUserDTO{ + {Login: testUserLogin, Email: "testUser@grafana.com"}, + {Login: "user2", Email: "user2@grafana.com"}, + }, + } + orgService.SearchOrgUsersFn = func(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { + require.True(t, query.ExcludeHiddenUsers) + return orgService.ExpectedSearchOrgUsersResult, nil + } + defer func() { orgService.SearchOrgUsersFn = nil }() + sc.handlerFunc = hs.GetOrgUsersForCurrentOrgLookup sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index 7873e899eb3..ac0268e051c 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -188,6 +188,8 @@ type SearchOrgUsersQuery struct { SortOpts []model.SortOption // Flag used to allow oss edition to query users without access control DontEnforceAccessControl bool + // Flag used to exclude hidden users from the result + ExcludeHiddenUsers bool User identity.Requester } diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index 423a4bc8b8d..6df28368f4c 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -27,6 +27,7 @@ func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (org db: db, dialect: db.GetDialect(), log: log, + cfg: cfg, }, cfg: cfg, log: log, diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index 50bbd68ec82..7e03db60e34 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -16,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -53,6 +55,7 @@ type sqlStore struct { //TODO: moved to service log log.Logger deletes []string + cfg *setting.Cfg } func (ss *sqlStore) Get(ctx context.Context, orgID int64) (*org.Org, error) { @@ -560,6 +563,14 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser whereParams = append(whereParams, acFilter.Args...) } + if query.ExcludeHiddenUsers { + cond, params := buildHiddenUsersFilter(query.User, ss.cfg.HiddenUsers) + if cond != "" { + whereConditions = append(whereConditions, cond) + whereParams = append(whereParams, params...) + } + } + if query.Query != "" { sql1, param1 := ss.dialect.LikeOperator("email", true, query.Query, true) sql2, param2 := ss.dialect.LikeOperator("name", true, query.Query, true) @@ -825,3 +836,23 @@ func removeUserOrg(sess *db.Session, userID int64) error { func (ss *sqlStore) RegisterDelete(query string) { ss.deletes = append(ss.deletes, query) } + +func buildHiddenUsersFilter(requester identity.Requester, hiddenUsersMap map[string]struct{}) (string, []any) { + if requester != nil && requester.GetIsGrafanaAdmin() { + return "", nil + } + + hiddenUsers := make([]any, 0) + for user := range hiddenUsersMap { + if requester != nil && user == requester.GetLogin() { + continue + } + hiddenUsers = append(hiddenUsers, user) + } + + if len(hiddenUsers) > 0 { + return "u.login NOT IN (?" + strings.Repeat(",?", len(hiddenUsers)-1) + ")", hiddenUsers + } + + return "", nil +} diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 5cd7c356a5c..54f8e9fda39 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -820,8 +820,9 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { db: store, dialect: store.GetDialect(), log: log.NewNopLogger(), + cfg: cfg, } - // orgUserStore.cfg.Skip + orgSvc, userSvc := createOrgAndUserSvc(t, store, cfg) o, err := orgSvc.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "test org"}) @@ -829,6 +830,14 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { seedOrgUsers(t, &orgUserStore, 10, userSvc, o.ID) + user1, err := userSvc.GetByLogin(context.Background(), &user.GetUserByLoginQuery{LoginOrEmail: "user-1"}) + require.NoError(t, err) + + cfg.HiddenUsers = map[string]struct{}{ + "user-1": {}, + "user-2": {}, + } + tests := []struct { desc string query *org.SearchOrgUsersQuery @@ -840,7 +849,7 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { OrgID: o.ID, User: &user.SignedInUser{ OrgID: o.ID, - Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, }, }, expectedNumUsers: 10, @@ -851,7 +860,7 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { OrgID: o.ID, User: &user.SignedInUser{ OrgID: o.ID, - Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: {""}}}, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {""}}}, }, }, expectedNumUsers: 0, @@ -862,8 +871,8 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { OrgID: o.ID, User: &user.SignedInUser{ OrgID: o.ID, - Permissions: map[int64]map[string][]string{1: {accesscontrol.ActionOrgUsersRead: { - "users:id:1", + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: { + "users:id:2", "users:id:5", "users:id:9", }}}, @@ -871,6 +880,55 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { }, expectedNumUsers: 3, }, + { + desc: "should exclude hidden users when ExcludeHiddenUsers is true and user is nil", + query: &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: true, + User: nil, + DontEnforceAccessControl: true, + }, + expectedNumUsers: 8, + }, + { + desc: "should not exclude hidden users when ExcludeHiddenUsers is true and user is Grafana Admin", + query: &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: true, + User: &user.SignedInUser{ + OrgID: o.ID, + IsGrafanaAdmin: true, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + }, + expectedNumUsers: 10, + }, + { + desc: "should return all users if ExcludeHiddenUsers is false", + query: &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: false, + User: &user.SignedInUser{ + OrgID: o.ID, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + }, + expectedNumUsers: 10, + }, + { + desc: "should include the hidden user when the request is made by the hidden user and ExcludeHiddenUsers is true", + query: &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: true, + User: &user.SignedInUser{ + UserID: user1.ID, + Login: user1.Login, + OrgID: o.ID, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + }, + expectedNumUsers: 9, + }, } for _, tt := range tests { @@ -879,13 +937,58 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { require.NoError(t, err) assert.Len(t, result.OrgUsers, tt.expectedNumUsers) - if !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) { + // No pagination is applied, so TotalCount should equal to number of returned users + assert.Equal(t, int64(tt.expectedNumUsers), result.TotalCount) + + if tt.query.User != nil && !hasWildcardScope(tt.query.User, accesscontrol.ActionOrgUsersRead) && !tt.query.User.GetIsGrafanaAdmin() { for _, u := range result.OrgUsers { assert.Contains(t, tt.query.User.GetPermissions()[accesscontrol.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserID)) } } }) } + + t.Run("should paginate correctly when ExcludeHiddenUsers is true", func(t *testing.T) { + query := &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: true, + User: &user.SignedInUser{ + OrgID: o.ID, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + Limit: 5, + Page: 1, + } + result, err := orgUserStore.SearchOrgUsers(context.Background(), query) + require.NoError(t, err) + assert.Len(t, result.OrgUsers, 5) + assert.Equal(t, int64(8), result.TotalCount) + + query.Page = 2 + result, err = orgUserStore.SearchOrgUsers(context.Background(), query) + require.NoError(t, err) + assert.Len(t, result.OrgUsers, 3) + assert.Equal(t, int64(8), result.TotalCount) + }) + + t.Run("should return all users if HiddenUsers is empty", func(t *testing.T) { + oldHiddenUsers := cfg.HiddenUsers + cfg.HiddenUsers = make(map[string]struct{}) + defer func() { cfg.HiddenUsers = oldHiddenUsers }() + + query := &org.SearchOrgUsersQuery{ + OrgID: o.ID, + ExcludeHiddenUsers: true, + User: &user.SignedInUser{ + OrgID: o.ID, + Permissions: map[int64]map[string][]string{o.ID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + } + result, err := orgUserStore.SearchOrgUsers(context.Background(), query) + require.NoError(t, err) + assert.Len(t, result.OrgUsers, 10) + assert.Equal(t, int64(10), result.TotalCount) + }) } func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { From 3fe8e704365a691e8af3fc2119786dbc3b2822bf Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Tue, 16 Dec 2025 10:14:06 +0100 Subject: [PATCH 04/21] Enhancement: Introduce optimized folder permission relations (#115247) Enhancement: Introduce optimized folder permission relations and new permission definitions - Added `can_get_permissions` and `can_set_permissions` relations to enhance permission management. - Implemented `FolderPermissionRelation` function to optimize permission checks for folder resources. - Updated `checkTyped` and `listTyped` methods to utilize optimized relations for permission management. - Introduced a new benchmark test file for performance evaluation of permission checks and listings. --- pkg/services/authz/zanzana/common/tuple.go | 27 + .../authz/zanzana/schema/schema_folder.fga | 26 +- .../authz/zanzana/server/server_bench_test.go | 947 ++++++++++++++++++ .../authz/zanzana/server/server_check.go | 17 +- .../authz/zanzana/server/server_list.go | 15 +- 5 files changed, 1013 insertions(+), 19 deletions(-) create mode 100644 pkg/services/authz/zanzana/server/server_bench_test.go diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index 7f0faff1f18..38f38fb90a7 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -58,6 +58,13 @@ const ( RelationGetPermissions string = "get_permissions" RelationSetPermissions string = "set_permissions" + RelationCanGet string = "can_get" + RelationCanCreate string = "can_create" + RelationCanUpdate string = "can_update" + RelationCanDelete string = "can_delete" + RelationCanGetPermissions string = "can_get_permissions" + RelationCanSetPermissions string = "can_set_permissions" + RelationSubresourceSetView string = "resource_" + RelationSetView RelationSubresourceSetEdit string = "resource_" + RelationSetEdit RelationSubresourceSetAdmin string = "resource_" + RelationSetAdmin @@ -134,6 +141,26 @@ var RelationToVerbMapping = map[string]string{ RelationSetPermissions: utils.VerbSetPermissions, } +// FolderPermissionRelation returns the optimized folder relation for permission management. +func FolderPermissionRelation(relation string) string { + switch relation { + case RelationGet: + return RelationCanGet + case RelationCreate: + return RelationCanCreate + case RelationUpdate: + return RelationCanUpdate + case RelationDelete: + return RelationCanDelete + case RelationGetPermissions: + return RelationCanGetPermissions + case RelationSetPermissions: + return RelationCanSetPermissions + default: + return relation + } +} + func IsGroupResourceRelation(relation string) bool { return isValidRelation(relation, RelationsGroupResource) } diff --git a/pkg/services/authz/zanzana/schema/schema_folder.fga b/pkg/services/authz/zanzana/schema/schema_folder.fga index b9b0a842de9..c55d1312f53 100644 --- a/pkg/services/authz/zanzana/schema/schema_folder.fga +++ b/pkg/services/authz/zanzana/schema/schema_folder.fga @@ -4,15 +4,21 @@ type folder relations define parent: [folder] - # Action sets - define view: [user, service-account, team#member, role#assignee] or edit or view from parent - define edit: [user, service-account, team#member, role#assignee] or admin or edit from parent + # Permission levels define admin: [user, service-account, team#member, role#assignee] or admin from parent + define edit: [user, service-account, team#member, role#assignee] or edit from parent + define view: [user, service-account, team#member, role#assignee] or view from parent + define get: [user, service-account, team#member, role#assignee] or get from parent + define create: [user, service-account, team#member, role#assignee] or create from parent + define update: [user, service-account, team#member, role#assignee] or update from parent + define delete: [user, service-account, team#member, role#assignee] or delete from parent + define get_permissions: [user, service-account, team#member, role#assignee] or get_permissions from parent + define set_permissions: [user, service-account, team#member, role#assignee] or set_permissions from parent - define get: [user, service-account, team#member, role#assignee] or view or get from parent - define create: [user, service-account, team#member, role#assignee] or edit or create from parent - define update: [user, service-account, team#member, role#assignee] or edit or update from parent - define delete: [user, service-account, team#member, role#assignee] or edit or delete from parent - - define get_permissions: [user, service-account, team#member, role#assignee] or admin or get_permissions from parent - define set_permissions: [user, service-account, team#member, role#assignee] or admin or set_permissions from parent + # Computed actions + define can_get: admin or edit or view or get + define can_create: admin or edit or create + define can_update: admin or edit or update + define can_delete: admin or edit or delete + define can_get_permissions: admin or get_permissions + define can_set_permissions: admin or set_permissions diff --git a/pkg/services/authz/zanzana/server/server_bench_test.go b/pkg/services/authz/zanzana/server/server_bench_test.go new file mode 100644 index 00000000000..98ec58560b0 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_bench_test.go @@ -0,0 +1,947 @@ +package server + +import ( + "context" + "fmt" + "math/rand" + "testing" + "time" + + authzv1 "github.com/grafana/authlib/authz/proto/v1" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" + "github.com/grafana/grafana/pkg/services/authz/zanzana/store" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + benchNamespace = "default" + + // Folder tree parameters + foldersPerLevel = 3 + folderDepth = 7 + + // Other data generation parameters + numResources = 50000 + numUsers = 1000 + numTeams = 100 + + // Timeout for List operations + listTimeout = 30 * time.Second + + // Resource type constants for benchmarks + benchDashboardGroup = "dashboard.grafana.app" + benchDashboardResource = "dashboards" + benchFolderGroup = "folder.grafana.app" + benchFolderResource = "folders" + + // BenchmarkBatchCheck measures the performance of BatchCheck requests with 50 items per batch. + batchCheckSize = 50 +) + +// benchmarkData holds all the generated test data for benchmarks +type benchmarkData struct { + folders []string // folder UIDs + folderDepths map[string]int // folder UID -> depth level + folderParents map[string]string // folder UID -> parent UID + folderDescendants map[string]int // folder UID -> number of descendants (including self) + foldersByDepth [][]string // folders grouped by depth level + resources []string // resource names + resourceFolders map[string]string // resource name -> folder UID + users []string // user identifiers (e.g., "user:1") + teams []string // team identifiers (e.g., "team:1") + + // Pre-computed test scenarios + deepestFolder string // folder at max depth for worst-case tests + midDepthFolder string // folder at depth/2 + shallowFolder string // folder at depth 1 + rootFolder string // root level folder (depth 0) + largestRootFolder string // root folder with most descendants + largestRootDescCount int // number of descendants in largestRootFolder + maxDepth int // maximum depth in the tree +} + +// generateFolderHierarchy creates a balanced tree of folders. +// Each folder has `childrenPerFolder` children, up to `depth` levels deep. +func generateFolderHierarchy(childrenPerFolder, depth int) ([]*openfgav1.TupleKey, *benchmarkData) { + // Calculate total folders: childrenPerFolder + childrenPerFolder^2 + ... + childrenPerFolder^(depth+1) + totalFolders := 0 + levelSize := childrenPerFolder + for d := 0; d <= depth; d++ { + totalFolders += levelSize + levelSize *= childrenPerFolder + } + + data := &benchmarkData{ + folders: make([]string, 0, totalFolders), + folderDepths: make(map[string]int), + folderParents: make(map[string]string), + folderDescendants: make(map[string]int), + } + tuples := make([]*openfgav1.TupleKey, 0, totalFolders) + + folderIdx := 0 + + // Track folders at each level for parent assignment + levelFolders := make([][]string, depth+1) + for i := range levelFolders { + levelFolders[i] = make([]string, 0) + } + + // Create root level folders (depth 0) + for i := 0; i < childrenPerFolder; i++ { + folderUID := fmt.Sprintf("folder-%d", folderIdx) + data.folders = append(data.folders, folderUID) + data.folderDepths[folderUID] = 0 + levelFolders[0] = append(levelFolders[0], folderUID) + folderIdx++ + } + + // Create folders at each subsequent depth level + for d := 1; d <= depth; d++ { + parentFolders := levelFolders[d-1] + + // Each parent gets exactly childrenPerFolder children + for _, parentUID := range parentFolders { + for j := 0; j < childrenPerFolder; j++ { + folderUID := fmt.Sprintf("folder-%d", folderIdx) + + data.folders = append(data.folders, folderUID) + data.folderDepths[folderUID] = d + data.folderParents[folderUID] = parentUID + levelFolders[d] = append(levelFolders[d], folderUID) + + // Create parent relationship tuple + tuples = append(tuples, common.NewFolderParentTuple(folderUID, parentUID)) + folderIdx++ + } + } + } + + // Set reference folders for different depth scenarios + data.rootFolder = levelFolders[0][0] + data.shallowFolder = levelFolders[0][0] + if len(levelFolders[1]) > 0 { + data.shallowFolder = levelFolders[1][0] + } + midDepth := depth / 2 + if len(levelFolders[midDepth]) > 0 { + data.midDepthFolder = levelFolders[midDepth][0] + } + // Deepest folder + if len(levelFolders[depth]) > 0 { + data.deepestFolder = levelFolders[depth][0] + } + + // Calculate descendant counts for each folder (bottom-up) + // Initialize all folders with count of 1 (self) + for _, folder := range data.folders { + data.folderDescendants[folder] = 1 + } + // Process folders from deepest to shallowest, accumulating descendant counts + for d := depth; d >= 0; d-- { + for _, folder := range levelFolders[d] { + if parent, hasParent := data.folderParents[folder]; hasParent { + data.folderDescendants[parent] += data.folderDescendants[folder] + } + } + } + + // Find root folder with most descendants + for _, rootFolder := range levelFolders[0] { + count := data.folderDescendants[rootFolder] + if count > data.largestRootDescCount { + data.largestRootDescCount = count + data.largestRootFolder = rootFolder + } + } + + // Store folders by depth for depth-based testing + data.foldersByDepth = levelFolders + data.maxDepth = depth + + return tuples, data +} + +// generateResources creates resources distributed across folders +func generateResources(data *benchmarkData, numResources int) []*openfgav1.TupleKey { + data.resources = make([]string, numResources) + data.resourceFolders = make(map[string]string, numResources) + + // Distribute resources across folders + for i := 0; i < numResources; i++ { + resourceName := fmt.Sprintf("resource-%d", i) + folderIdx := i % len(data.folders) + folderUID := data.folders[folderIdx] + + data.resources[i] = resourceName + data.resourceFolders[resourceName] = folderUID + } + + // Note: We don't create tuples for resources themselves, + // permissions are assigned to users/teams on folders or directly on resources + return nil +} + +// generateUsers creates user identifiers +func generateUsers(data *benchmarkData, numUsers int) { + data.users = make([]string, numUsers) + for i := 0; i < numUsers; i++ { + data.users[i] = fmt.Sprintf("user:%d", i) + } +} + +// generateTeams creates team identifiers +func generateTeams(data *benchmarkData, numTeams int) { + data.teams = make([]string, numTeams) + for i := 0; i < numTeams; i++ { + data.teams[i] = fmt.Sprintf("team:%d", i) + } +} + +// generatePermissionTuples creates various permission assignments for benchmarking. +// Users are distributed across 7 patterns: global, root folder, mid-depth folder, +// folder-scoped resource, direct resource, team-based, and no permissions. +const numPermissionPatterns = 7 + +func generatePermissionTuples(data *benchmarkData) []*openfgav1.TupleKey { + tuples := make([]*openfgav1.TupleKey, 0) + + // Distribute users across different permission patterns + usersPerPattern := len(data.users) / numPermissionPatterns + + // Pattern 1: Users with GroupResource permission (all access) + // Users 0 to usersPerPattern-1 + for i := 0; i < usersPerPattern; i++ { + tuples = append(tuples, common.NewGroupResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + )) + } + + // Pattern 2: Users with folder-level permission on root folders + // Users usersPerPattern to 2*usersPerPattern-1 + for i := usersPerPattern; i < 2*usersPerPattern; i++ { + folderIdx := (i - usersPerPattern) % len(data.folders) + // Only assign to root-level folders for this pattern + for j := folderIdx; j < len(data.folders); j++ { + if data.folderDepths[data.folders[j]] == 0 { + tuples = append(tuples, common.NewFolderTuple( + data.users[i], + common.RelationSetView, + data.folders[j], + )) + break + } + } + } + + // Pattern 3: Users with folder-level permission on mid-depth folders + // Use relative depth range: 1/3 to 2/3 of max depth + // Use "view" relation which grants get through the optimized schema + minMidDepth := data.maxDepth / 3 + maxMidDepth := 2 * data.maxDepth / 3 + if maxMidDepth < minMidDepth { + maxMidDepth = minMidDepth + } + // Collect folders in the mid-depth range + var midDepthFolders []string + for d := minMidDepth; d <= maxMidDepth; d++ { + if d < len(data.foldersByDepth) { + midDepthFolders = append(midDepthFolders, data.foldersByDepth[d]...) + } + } + // Fall back to root folders if no mid-depth folders exist + if len(midDepthFolders) == 0 { + midDepthFolders = data.foldersByDepth[0] + } + for i := 2 * usersPerPattern; i < 3*usersPerPattern; i++ { + folderIdx := (i - 2*usersPerPattern) % len(midDepthFolders) + tuples = append(tuples, common.NewFolderTuple( + data.users[i], + common.RelationSetView, + midDepthFolders[folderIdx], + )) + } + + // Pattern 4: Users with folder-scoped resource permission + for i := 3 * usersPerPattern; i < 4*usersPerPattern; i++ { + folderIdx := (i - 3*usersPerPattern) % len(data.folders) + tuples = append(tuples, common.NewFolderResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + data.folders[folderIdx], + )) + } + + // Pattern 5: Users with direct resource permission + for i := 4 * usersPerPattern; i < 5*usersPerPattern; i++ { + resourceIdx := (i - 4*usersPerPattern) % len(data.resources) + tuples = append(tuples, common.NewResourceTuple( + data.users[i], + common.RelationGet, + benchDashboardGroup, + benchDashboardResource, + "", + data.resources[resourceIdx], + )) + } + + // Pattern 6: Team memberships and team permissions + // First, add users to teams + for i := 5 * usersPerPattern; i < 6*usersPerPattern && i < len(data.users); i++ { + teamIdx := (i - 5*usersPerPattern) % len(data.teams) + tuples = append(tuples, common.NewTypedTuple( + common.TypeTeam, + data.users[i], + common.RelationTeamMember, + fmt.Sprintf("%d", teamIdx), + )) + } + // Then, give teams folder permissions + // Use "view" relation which grants get through the optimized schema + for i := 0; i < len(data.teams); i++ { + folderIdx := i % len(data.folders) + teamMember := fmt.Sprintf("team:%d#member", i) + tuples = append(tuples, common.NewFolderTuple( + teamMember, + common.RelationSetView, + data.folders[folderIdx], + )) + } + + // Pattern 7: Users with no permissions (remaining users) + // These users don't get any tuples - they're for testing denial cases + + return tuples +} + +// setupBenchmarkServer creates a server with the benchmark data loaded +func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) { + b.Helper() + if testing.Short() { + b.Skip("skipping benchmark in short mode") + } + + cfg := setting.NewCfg() + testStore := sqlstore.NewTestStore(b, sqlstore.WithCfg(cfg)) + + openFGAStore, err := store.NewEmbeddedStore(cfg, testStore, log.NewNopLogger()) + require.NoError(b, err) + + openfga, err := NewOpenFGAServer(cfg.ZanzanaServer, openFGAStore) + require.NoError(b, err) + + srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService(), prometheus.NewRegistry()) + require.NoError(b, err) + + // Generate test data + b.Log("Generating folder hierarchy...") + folderTuples, data := generateFolderHierarchy(foldersPerLevel, folderDepth) + + b.Log("Generating resources...") + generateResources(data, numResources) + + b.Log("Generating users...") + generateUsers(data, numUsers) + + b.Log("Generating teams...") + generateTeams(data, numTeams) + + b.Log("Generating permission tuples...") + permTuples := generatePermissionTuples(data) + + // Add special user with permission on largest root folder (for >1000 folder test) + // Use "view" relation which grants get through the optimized schema + largeRootUserTuple := common.NewFolderTuple( + "user:large-root-access", + common.RelationSetView, + data.largestRootFolder, + ) + permTuples = append(permTuples, largeRootUserTuple) + + // Add users with permissions at each depth level for depth-based testing + // Use "view" relation which grants get through the optimized schema + for depth := 0; depth <= data.maxDepth; depth++ { + if len(data.foldersByDepth[depth]) == 0 { + continue + } + folder := data.foldersByDepth[depth][0] + user := fmt.Sprintf("user:depth-%d-access", depth) + permTuples = append(permTuples, common.NewFolderTuple(user, common.RelationSetView, folder)) + } + + // Combine all tuples + allTuples := append(folderTuples, permTuples...) + + b.Logf("Total tuples to write: %d", len(allTuples)) + + // Get store info + ctx := newContextWithNamespace() + storeInf, err := srv.getStoreInfo(ctx, benchNamespace) + require.NoError(b, err) + + // Write tuples in batches (OpenFGA limits to 100 per write) + batchSize := 100 + for i := 0; i < len(allTuples); i += batchSize { + end := i + batchSize + if end > len(allTuples) { + end = len(allTuples) + } + batch := allTuples[i:end] + + _, err = srv.openfga.Write(ctx, &openfgav1.WriteRequest{ + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, + Writes: &openfgav1.WriteRequestWrites{ + TupleKeys: batch, + OnDuplicate: "ignore", + }, + }) + require.NoError(b, err) + + if (i/batchSize)%100 == 0 { + b.Logf("Written %d/%d tuples", end, len(allTuples)) + } + } + + b.Logf("Benchmark data setup complete: %d folders, %d resources, %d users, %d teams", + len(data.folders), len(data.resources), len(data.users), len(data.teams)) + b.Logf("Largest root folder: %s with %d descendants", data.largestRootFolder, data.largestRootDescCount) + + return srv, data +} + +// BenchmarkCheck measures the performance of Check requests +func BenchmarkCheck(b *testing.B) { + srv, data := setupBenchmarkServer(b) + ctx := newContextWithNamespace() + + // Helper to create check requests + newCheckReq := func(subject, verb, group, resource, folder, name string) *authzv1.CheckRequest { + return &authzv1.CheckRequest{ + Namespace: benchNamespace, + Subject: subject, + Verb: verb, + Group: group, + Resource: resource, + Folder: folder, + Name: name, + } + } + + usersPerPattern := len(data.users) / 7 + + b.Run("GroupResourceDirect", func(b *testing.B) { + // User with group_resource permission - should have access to everything + user := data.users[0] // First user has GroupResource permission + resource := data.resources[rand.Intn(len(data.resources))] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + if !res.GetAllowed() { + b.Fatal("expected access to be allowed") + } + } + }) + + // Test folder inheritance at each depth level (0 to maxDepth) + // User has permission on ROOT folder (depth 0), we check access at each deeper level + rootUser := "user:depth-0-access" // has view permission on root folder + for depth := 0; depth <= data.maxDepth; depth++ { + depth := depth // capture for closure + if len(data.foldersByDepth[depth]) == 0 { + continue + } + b.Run(fmt.Sprintf("FolderInheritance/Depth%d", depth), func(b *testing.B) { + resource := data.resources[0] + folder := data.foldersByDepth[depth][0] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(rootUser, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + } + + b.Run("FolderResourceScoped", func(b *testing.B) { + // User with folder-scoped resource permission + user := data.users[3*usersPerPattern] + folderIdx := 0 + folder := data.folders[folderIdx] + resource := data.resources[folderIdx] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("DirectResource", func(b *testing.B) { + // User with direct resource permission + user := data.users[4*usersPerPattern] + resourceIdx := 0 + resource := data.resources[resourceIdx] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("TeamMembership", func(b *testing.B) { + // User who is a team member, team has folder permission + user := data.users[5*usersPerPattern] + teamIdx := 0 + folderIdx := teamIdx % len(data.folders) + folder := data.folders[folderIdx] + resource := data.resources[folderIdx%len(data.resources)] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - tests denial path + user := data.users[len(data.users)-1] // Last user has no permissions + resource := data.resources[0] + folder := data.resourceFolders[resource] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource, folder, resource)) + if err != nil { + b.Fatal(err) + } + if res.GetAllowed() { + b.Fatal("expected access to be denied") + } + } + }) + + b.Run("FolderCheck", func(b *testing.B) { + // Direct folder access check + user := data.users[usersPerPattern] + folder := data.rootFolder + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.Check(ctx, newCheckReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource, "", folder)) + if err != nil { + b.Fatal(err) + } + _ = res.GetAllowed() + } + }) +} + +func BenchmarkBatchCheck(b *testing.B) { + srv, data := setupBenchmarkServer(b) + ctx := newContextWithNamespace() + + // Helper to create batch check requests + newBatchCheckReq := func(subject string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest { + return &authzextv1.BatchCheckRequest{ + Namespace: benchNamespace, + Subject: subject, + Items: items, + } + } + + // Helper to create batch items for resources in folders + createBatchItems := func(resources []string, resourceFolders map[string]string) []*authzextv1.BatchCheckItem { + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for i := 0; i < batchCheckSize && i < len(resources); i++ { + resource := resources[i] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: resource, + Folder: resourceFolders[resource], + }) + } + return items + } + + // Helper to create batch items for folders at a specific depth + createFolderBatchItems := func(folders []string, depth int, folderDepths map[string]int) []*authzextv1.BatchCheckItem { + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for _, folder := range folders { + if folderDepths[folder] == depth && len(items) < batchCheckSize { + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-in-%s", folder), + Folder: folder, + }) + } + } + // Fill remaining slots if needed + for len(items) < batchCheckSize && len(folders) > 0 { + folder := folders[len(items)%len(folders)] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-%d", len(items)), + Folder: folder, + }) + } + return items + } + + usersPerPattern := len(data.users) / numPermissionPatterns + + b.Run("GroupResourceDirect", func(b *testing.B) { + // User with group_resource permission - should have access to everything + user := data.users[0] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth1", func(b *testing.B) { + // User with folder permission on shallow folder + user := data.users[usersPerPattern] + items := createFolderBatchItems(data.folders, 1, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth4", func(b *testing.B) { + // User with folder permission on mid-depth folder + user := data.users[2*usersPerPattern] + items := createFolderBatchItems(data.folders, 4, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("FolderInheritance/Depth7", func(b *testing.B) { + // Check access on deepest folders (worst case for inheritance traversal) + user := data.users[usersPerPattern] + items := createFolderBatchItems(data.folders, data.maxDepth, data.folderDepths) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("DirectResource", func(b *testing.B) { + // User with direct resource permission + user := data.users[4*usersPerPattern] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("TeamMembership", func(b *testing.B) { + // User who is a team member, team has folder permission + user := data.users[5*usersPerPattern] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - tests denial path + user := data.users[len(data.users)-1] + items := createBatchItems(data.resources, data.resourceFolders) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) + + b.Run("MixedFolders", func(b *testing.B) { + // Batch of items across different folder depths + user := data.users[usersPerPattern] + items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize) + for i := 0; i < batchCheckSize; i++ { + folder := data.folders[i%len(data.folders)] + items = append(items, &authzextv1.BatchCheckItem{ + Verb: utils.VerbGet, + Group: benchDashboardGroup, + Resource: benchDashboardResource, + Name: fmt.Sprintf("resource-%d", i), + Folder: folder, + }) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items)) + if err != nil { + b.Fatal(err) + } + _ = res.Groups + } + }) +} + +// BenchmarkList measures the performance of List requests (Compile equivalent) +func BenchmarkList(b *testing.B) { + srv, data := setupBenchmarkServer(b) + baseCtx := newContextWithNamespace() + + // Helper to create list requests + newListReq := func(subject, verb, group, resource string) *authzv1.ListRequest { + return &authzv1.ListRequest{ + Namespace: benchNamespace, + Subject: subject, + Verb: verb, + Group: group, + Resource: resource, + } + } + + // Helper to create context with timeout + ctxWithTimeout := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(baseCtx, listTimeout) + } + + usersPerPattern := len(data.users) / 7 + + b.Run("AllAccess", func(b *testing.B) { + // User with group_resource permission - should return All=true quickly + user := data.users[0] + b.Logf("Test: User with group_resource permission (access to ALL dashboards)") + b.Logf("Expected: All=true returned immediately without ListObjects call") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if !res.GetAll() { + b.Fatal("expected All=true for user with group_resource permission") + } + } + }) + + b.Run("FolderScoped", func(b *testing.B) { + // User with folder permissions - should return folder list + user := data.users[usersPerPattern] + b.Logf("Test: User with direct folder permission on a single folder") + b.Logf("Expected: Returns list of folders user has access to") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("DirectResources", func(b *testing.B) { + // User with direct resource permissions - should return items list + user := data.users[4*usersPerPattern] + b.Logf("Test: User with direct permission on specific resources") + b.Logf("Expected: Returns list of specific resources user has access to") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("NoAccess", func(b *testing.B) { + // User with no permissions - should return empty results + user := data.users[len(data.users)-1] + b.Logf("Test: User with NO permissions (denial case)") + b.Logf("Expected: Empty results") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchDashboardGroup, benchDashboardResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + if i == 0 { + b.Logf("Result: %d folders, %d items, All=%v", len(res.GetFolders()), len(res.GetItems()), res.GetAll()) + } + } + }) + + b.Run("LargeRootFolder", func(b *testing.B) { + // User with access to root folder that has many descendants + user := "user:large-root-access" + b.Logf("Test: User with permission on ROOT folder (folder-0)") + b.Logf("Root folder %s has %d total descendants", data.largestRootFolder, data.largestRootDescCount) + b.Logf("Expected: ListObjects should return folders through inheritance") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + start := time.Now() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + elapsed := time.Since(start) + cancel() + if err != nil { + b.Fatalf("Error after %v: %v", elapsed, err) + } + if i == 0 { + b.Logf("Result: %d folders returned in %v (descendants: %d)", + len(res.GetItems()), elapsed, data.largestRootDescCount) + } + } + }) + + // Test List at various folder depths to find breaking point + b.Run("ByDepth", func(b *testing.B) { + b.Logf("Testing List performance at various folder depths (timeout: %v)", listTimeout) + b.Logf("Tree structure: %d folders per level, %d max depth", foldersPerLevel, data.maxDepth) + + for depth := 0; depth <= data.maxDepth; depth++ { + if len(data.foldersByDepth[depth]) == 0 { + continue + } + + folder := data.foldersByDepth[depth][0] + descendants := data.folderDescendants[folder] + user := fmt.Sprintf("user:depth-%d-access", depth) + + b.Run(fmt.Sprintf("Depth%d_%dDescendants", depth, descendants), func(b *testing.B) { + b.Logf("Test: User with permission on folder at depth %d", depth) + b.Logf("Folder: %s, Descendants: %d", folder, descendants) + + // First, do a single timed run to report + ctx, cancel := ctxWithTimeout() + start := time.Now() + res, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + elapsed := time.Since(start) + cancel() + + if err != nil { + b.Logf("FAILED after %v: %v", elapsed, err) + if elapsed >= listTimeout { + b.Logf("TIMEOUT: List took longer than %v", listTimeout) + } + b.Skip("Skipping benchmark iterations due to error") + return + } + + b.Logf("Result: %d folders in %v", len(res.GetItems()), elapsed) + + if elapsed > 5*time.Second { + b.Logf("WARNING: Single List took %v, skipping benchmark iterations", elapsed) + b.Skip("Too slow for benchmark iterations") + return + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx, cancel := ctxWithTimeout() + _, err := srv.List(ctx, newListReq(user, utils.VerbGet, benchFolderGroup, benchFolderResource)) + cancel() + if err != nil { + b.Fatalf("Error: %v", err) + } + } + }) + } + }) +} diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index 916c84e5c0a..2f49641f17f 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -126,8 +126,14 @@ func (s *Server) checkTyped(ctx context.Context, subject, relation string, resou return &authzv1.CheckResponse{Allowed: false}, nil } + // Use optimized folder permission relations for permission management + checkRelation := relation + if resource.Type() == common.TypeFolder { + checkRelation = common.FolderPermissionRelation(relation) + } + // Check if subject has direct access to resource - res, err := s.openfgaCheck(ctx, store, subject, relation, resourceIdent, contextuals, nil) + res, err := s.openfgaCheck(ctx, store, subject, checkRelation, resourceIdent, contextuals, nil) if err != nil { return nil, err } @@ -143,14 +149,15 @@ func (s *Server) checkGeneric(ctx context.Context, subject, relation string, res defer span.End() var ( - folderIdent = resource.FolderIdent() - resourceCtx = resource.Context() - folderRelation = common.SubresourceRelation(relation) + folderIdent = resource.FolderIdent() + resourceCtx = resource.Context() + folderRelation = common.SubresourceRelation(relation) + folderCheckRelation = common.FolderPermissionRelation(relation) ) if folderIdent != "" && isFolderPermissionBasedResource(resource.GroupResource()) { // Check if resource inherits permissions from the folder (like dashboards in a folder) - res, err := s.openfgaCheck(ctx, store, subject, relation, folderIdent, contextuals, resourceCtx) + res, err := s.openfgaCheck(ctx, store, subject, folderCheckRelation, folderIdent, contextuals, resourceCtx) if err != nil { return nil, err } diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index 216e8df933e..9734f186d2a 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -85,6 +85,12 @@ func (s *Server) listTyped(ctx context.Context, subject, relation string, resour resourceCtx = resource.Context() ) + // Use optimized folder permission relations for permission management + listRelation := relation + if resource.Type() == common.TypeFolder { + listRelation = common.FolderPermissionRelation(relation) + } + var items []string if resource.HasSubresource() && common.IsSubresourceRelation(subresourceRelation) { // List requested subresources @@ -110,7 +116,7 @@ func (s *Server) listTyped(ctx context.Context, subject, relation string, resour StoreId: store.ID, AuthorizationModelId: store.ModelID, Type: resource.Type(), - Relation: relation, + Relation: listRelation, User: subject, ContextualTuples: contextuals, }) @@ -129,8 +135,9 @@ func (s *Server) listGeneric(ctx context.Context, subject, relation string, reso defer span.End() var ( - folderRelation = common.SubresourceRelation(relation) - resourceCtx = resource.Context() + folderRelation = common.SubresourceRelation(relation) + folderListRelation = common.FolderPermissionRelation(relation) // Optimized for permission management + resourceCtx = resource.Context() ) // 1. List all folders subject has access to resource type in @@ -159,7 +166,7 @@ func (s *Server) listGeneric(ctx context.Context, subject, relation string, reso StoreId: store.ID, AuthorizationModelId: store.ModelID, Type: common.TypeFolder, - Relation: relation, + Relation: folderListRelation, User: subject, Context: resourceCtx, ContextualTuples: contextuals, From a5f52fb40daedf7dc6ec9252aec58d3cb76910b8 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Tue, 16 Dec 2025 10:35:04 +0100 Subject: [PATCH 05/21] Dashboards: Fix links not wrapping (#115393) * Fix links not wrapping * also fix margin for links to dashboards --- .../features/dashboard-scene/scene/DashboardLinkRenderer.tsx | 1 - .../features/dashboard-scene/scene/DashboardLinksControls.tsx | 2 ++ .../dashboard/components/SubMenu/DashboardLinksDashboard.tsx | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx index d2571314782..92ea9aa473f 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx @@ -63,7 +63,6 @@ function getStyles(theme: GrafanaTheme2) { display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', - marginBottom: theme.spacing(1), }), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index 46fcf64d121..d95c54440c9 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -39,6 +39,8 @@ function getStyles(theme: GrafanaTheme2) { display: 'inline-flex', gap: theme.spacing(1), marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + flexWrap: 'wrap', }), }; } diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index ae5cffb9b31..f2d0d9e026f 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -181,7 +181,6 @@ function getStyles(theme: GrafanaTheme2) { display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', - marginBottom: theme.spacing(1), }), }; } From 409a1d88f13461a983944571759414e991d9a9dc Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 16 Dec 2025 10:36:46 +0100 Subject: [PATCH 06/21] Auditing: Refactor policy rule provider and add default policy rule evaluator (#115318) * Auditing: Add policy rule provider to fix wiring * Auditing: Add default policy rule evaluator for APIs --- pkg/apiserver/auditing/noop.go | 11 +++- pkg/apiserver/auditing/policy.go | 59 +++++++++++++++++++ pkg/apiserver/auditing/policy_test.go | 73 ++++++++++++++++++++++++ pkg/registry/apis/wireset.go | 2 +- pkg/server/wire_gen.go | 8 +-- pkg/services/apiserver/builder/common.go | 8 +++ pkg/services/apiserver/builder/helper.go | 27 +++++++++ pkg/services/apiserver/service.go | 11 ++-- 8 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 pkg/apiserver/auditing/policy.go create mode 100644 pkg/apiserver/auditing/policy_test.go diff --git a/pkg/apiserver/auditing/noop.go b/pkg/apiserver/auditing/noop.go index 5ab8f902c19..5a6b39a3b71 100644 --- a/pkg/apiserver/auditing/noop.go +++ b/pkg/apiserver/auditing/noop.go @@ -19,11 +19,18 @@ func (NoopBackend) Shutdown() {} func (NoopBackend) String() string { return "" } +// NoopPolicyRuleProvider is a no-op implementation of PolicyRuleProvider +type NoopPolicyRuleProvider struct{} + +func ProvideNoopPolicyRuleProvider() PolicyRuleProvider { return &NoopPolicyRuleProvider{} } + +func (NoopPolicyRuleProvider) PolicyRuleProvider(PolicyRuleEvaluators) audit.PolicyRuleEvaluator { + return NoopPolicyRuleEvaluator{} +} + // NoopPolicyRuleEvaluator is a no-op implementation of audit.PolicyRuleEvaluator type NoopPolicyRuleEvaluator struct{} -func ProvideNoopPolicyRuleEvaluator() audit.PolicyRuleEvaluator { return &NoopPolicyRuleEvaluator{} } - func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig { return audit.RequestAuditConfig{Level: auditinternal.LevelNone} } diff --git a/pkg/apiserver/auditing/policy.go b/pkg/apiserver/auditing/policy.go new file mode 100644 index 00000000000..e88acf7c4cc --- /dev/null +++ b/pkg/apiserver/auditing/policy.go @@ -0,0 +1,59 @@ +package auditing + +import ( + "slices" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "k8s.io/apimachinery/pkg/runtime/schema" + auditinternal "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/audit" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +// PolicyRuleEvaluators is a map of API group+version to audit.PolicyRuleEvaluator +type PolicyRuleEvaluators = map[schema.GroupVersion]audit.PolicyRuleEvaluator + +type PolicyRuleProvider interface { + PolicyRuleProvider(evaluators PolicyRuleEvaluators) audit.PolicyRuleEvaluator +} + +// PolicyRuleEvaluator alias for easier imports. +type PolicyRuleEvaluator = audit.PolicyRuleEvaluator + +// DefaultGrafanaPolicyRuleEvaluator provides a sane default configuration for audit logging for API group+versions. +type defaultGrafanaPolicyRuleEvaluator struct{} + +var _ PolicyRuleEvaluator = &defaultGrafanaPolicyRuleEvaluator{} + +func NewDefaultGrafanaPolicyRuleEvaluator() audit.PolicyRuleEvaluator { + return defaultGrafanaPolicyRuleEvaluator{} +} + +func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Attributes) audit.RequestAuditConfig { + // Skip non-resource and watch requests otherwise it is too noisy. + if !attrs.IsResourceRequest() || attrs.GetVerb() == utils.VerbWatch { + return audit.RequestAuditConfig{ + Level: auditinternal.LevelNone, + } + } + + // Skip auditing if the user is part of the privileged group. + // The loopback client uses this group, so requests initiated in `/api/` would be duplicated. + if u := attrs.GetUser(); u != nil && slices.Contains(u.GetGroups(), user.SystemPrivilegedGroup) { + return audit.RequestAuditConfig{ + Level: auditinternal.LevelNone, + } + } + + return audit.RequestAuditConfig{ + Level: auditinternal.LevelMetadata, + OmitStages: []auditinternal.Stage{ + // Only log on StageResponseComplete + auditinternal.StageRequestReceived, + auditinternal.StageResponseStarted, + auditinternal.StagePanic, + }, + OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling. + } +} diff --git a/pkg/apiserver/auditing/policy_test.go b/pkg/apiserver/auditing/policy_test.go new file mode 100644 index 00000000000..af18f9110fd --- /dev/null +++ b/pkg/apiserver/auditing/policy_test.go @@ -0,0 +1,73 @@ +package auditing_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/apiserver/auditing" + "github.com/stretchr/testify/require" + auditinternal "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { + t.Parallel() + + evaluator := auditing.NewDefaultGrafanaPolicyRuleEvaluator() + require.NotNil(t, evaluator) + + t.Run("returns audit level none for non-resource requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: false, + } + + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelNone, config.Level) + }) + + t.Run("returns audit level none for watch requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbWatch, + } + + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelNone, config.Level) + }) + + t.Run("returns audit level none for requests from privileged group", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbCreate, + User: &user.DefaultInfo{ + Groups: []string{"test-group", user.SystemPrivilegedGroup}, + }, + } + + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelNone, config.Level) + }) + + t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbCreate, + User: &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"test-group"}, + }, + } + + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelMetadata, config.Level) + }) +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 740f2a46cef..df38965759b 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -37,7 +37,7 @@ var WireSetExts = wire.NewSet( // Auditing Options auditing.ProvideNoopBackend, - auditing.ProvideNoopPolicyRuleEvaluator, + auditing.ProvideNoopPolicyRuleProvider, ) var provisioningExtras = wire.NewSet( diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index cdc3371db11..6e068337a29 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -834,8 +834,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) backend := auditing.ProvideNoopBackend() - policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator() - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator) + policyRuleProvider := auditing.ProvideNoopPolicyRuleProvider() + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleProvider) if err != nil { return nil, err } @@ -1495,8 +1495,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) backend := auditing.ProvideNoopBackend() - policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator() - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator) + policyRuleProvider := auditing.ProvideNoopPolicyRuleProvider() + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleProvider) if err != nil { return nil, err } diff --git a/pkg/services/apiserver/builder/common.go b/pkg/services/apiserver/builder/common.go index bebbad8e8a6..e5e46a3340d 100644 --- a/pkg/services/apiserver/builder/common.go +++ b/pkg/services/apiserver/builder/common.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/generic" genericapiserver "k8s.io/apiserver/pkg/server" @@ -59,6 +60,13 @@ type APIGroupAuthorizer interface { GetAuthorizer() authorizer.Authorizer } +// APIGroupAuditor allows different API groups to opt-in and provide their own auditing policy evaluator function. +// Auditing is only enabled if this is implemented. If no customization is needed, you can use the default evaluator, +// `pkg/apiserver/auditing.NewDefaultGrafanaPolicyRuleEvaluator()`. +type APIGroupAuditor interface { + GetPolicyRuleEvaluator() audit.PolicyRuleEvaluator +} + type APIGroupMutation interface { // Mutate allows the builder to make changes to the object before it is persisted. // Context is used only for timeout/deadline/cancellation and tracing information. diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index a76a01dffba..c535443a91e 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -29,6 +29,7 @@ import ( "k8s.io/klog/v2" "k8s.io/kube-openapi/pkg/common" + "github.com/grafana/grafana/pkg/apiserver/auditing" "github.com/grafana/grafana/pkg/apiserver/endpoints/filters" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -497,6 +498,32 @@ func AddPostStartHooks( return nil } +func EvaluatorPolicyRuleFromBuilders(builders []APIGroupBuilder) auditing.PolicyRuleEvaluators { + policyRuleEvaluators := make(auditing.PolicyRuleEvaluators, 0) + + for _, b := range builders { + auditor, ok := b.(APIGroupAuditor) + if !ok { + continue + } + + policyRuleEvaluator := auditor.GetPolicyRuleEvaluator() + if policyRuleEvaluator == nil { + continue + } + + for _, gv := range GetGroupVersions(b) { + if gv.Empty() { + continue + } + + policyRuleEvaluators[gv] = policyRuleEvaluator + } + } + + return policyRuleEvaluators +} + func allowRegisteringResourceByInfo(allowedResources []string, name string) bool { // trim any subresources from the name name = strings.Split(name, "/")[0] diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 5d9e37e649e..6c92350ec2a 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -28,6 +28,7 @@ import ( dataplaneaggregator "github.com/grafana/grafana/pkg/aggregator/apiserver" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/auditing" grafanaresponsewriter "github.com/grafana/grafana/pkg/apiserver/endpoints/responsewriter" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" @@ -115,8 +116,8 @@ type service struct { builderMetrics *builder.BuilderMetrics dualWriterMetrics *grafanarest.DualWriterMetrics - auditBackend audit.Backend - auditPolicyRuleEvaluator audit.PolicyRuleEvaluator + auditBackend audit.Backend + auditPolicyRuleProvider auditing.PolicyRuleProvider } func ProvideService( @@ -142,7 +143,7 @@ func ProvideService( appInstallers []appsdkapiserver.AppInstaller, builderMetrics *builder.BuilderMetrics, auditBackend audit.Backend, - auditPolicyRuleEvaluator audit.PolicyRuleEvaluator, + auditPolicyRuleProvider auditing.PolicyRuleProvider, ) (*service, error) { scheme := builder.ProvideScheme() codecs := builder.ProvideCodecFactory(scheme) @@ -174,7 +175,7 @@ func ProvideService( builderMetrics: builderMetrics, dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg), auditBackend: auditBackend, - auditPolicyRuleEvaluator: auditPolicyRuleEvaluator, + auditPolicyRuleProvider: auditPolicyRuleProvider, } // This will be used when running as a dskit service s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer) @@ -365,7 +366,7 @@ func (s *service) start(ctx context.Context) error { // Auditing Options serverConfig.AuditBackend = s.auditBackend - serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleEvaluator + serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleProvider.PolicyRuleProvider(builder.EvaluatorPolicyRuleFromBuilders(s.builders)) // Add OpenAPI specs for each group+version (existing builders) err = builder.SetupConfig( From 5f6ff3a890327db79fad79d62329a8e13fa14cf7 Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:42:06 +0100 Subject: [PATCH 07/21] CI: remove broken step from release-comms.yml (#115397) remove broken step from release-comms.yml --- .github/workflows/release-comms.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index 58ebe84dbc0..fb65b2a3eed 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -111,12 +111,13 @@ jobs: ownerRepo: 'grafana/grafana-enterprise' from: ${{ needs.setup.outputs.release_branch }} to: ${{ needs.create_next_release_branch_enterprise.outputs.branch }} - post_changelog_on_forum: - needs: setup - uses: grafana/grafana/.github/workflows/community-release.yml@main - with: - version: ${{ needs.setup.outputs.version }} - dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} + # Removed this for now since it doesn't work + # post_changelog_on_forum: + # needs: setup + # uses: grafana/grafana/.github/workflows/community-release.yml@main + # with: + # version: ${{ needs.setup.outputs.version }} + # dry_run: ${{ needs.setup.outputs.dry_run == 'true' }} create_github_release: # a github release requires a git tag # The github-release action retrieves the changelog using the /repos/grafana/grafana/contents/CHANGELOG.md API From bf753c621a28fe8b0f9c161b73526b35153af784 Mon Sep 17 00:00:00 2001 From: Artur Minchukou Date: Tue, 16 Dec 2025 13:48:09 +0400 Subject: [PATCH 08/21] Trace datasources: Add Victoria Metrics support for "traces to metrics" (#114962) --- .../src/TraceToMetrics/TraceToMetricsSettings.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx index fbb68ab9a51..4bb0abec43d 100644 --- a/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx @@ -35,6 +35,10 @@ export interface TraceToMetricsData extends DataSourceJsonData { interface Props extends DataSourcePluginOptionsEditorProps {} export function TraceToMetricsSettings({ options, onOptionsChange }: Props) { + const supportedDataSourceTypes = [ + 'prometheus', + 'victoriametrics-metrics-datasource', // external + ]; const styles = useStyles2(getStyles); return ( @@ -47,10 +51,10 @@ export function TraceToMetricsSettings({ options, onOptionsChange }: Props) { > supportedDataSourceTypes.includes(ds.type)} onChange={(ds: DataSourceInstanceSettings) => updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', { ...options.jsonData.tracesToMetrics, From 7913b20ccaca2e37f1553f83d69b9bd28103d217 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Tue, 16 Dec 2025 11:02:40 +0100 Subject: [PATCH 09/21] Tracing: Fix excluding paths from tracing (#115394) fix: not tracing paths correctly --- pkg/api/http_server.go | 2 +- pkg/middleware/request_tracing.go | 16 ++++++++++------ pkg/services/frontend/frontend_service.go | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f2ac32a80c6..0898a5ecb66 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -638,7 +638,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m := hs.web m.Use(requestmeta.SetupRequestMetadata()) - m.Use(middleware.RequestTracing(hs.tracer, middleware.SkipTracingPaths)) + m.Use(middleware.RequestTracing(hs.tracer, middleware.ShouldTraceWithExceptions)) m.Use(middleware.RequestMetrics(hs.Features, hs.Cfg, hs.promRegister)) m.UseMiddleware(hs.LoggerMiddleware.Middleware()) diff --git a/pkg/middleware/request_tracing.go b/pkg/middleware/request_tracing.go index 998b20d7dbb..c06142a936a 100644 --- a/pkg/middleware/request_tracing.go +++ b/pkg/middleware/request_tracing.go @@ -73,16 +73,20 @@ func RouteOperationName(req *http.Request) (string, bool) { return "", false } -// Paths that don't need tracing spans applied to them because of the -// little value that would provide us -func SkipTracingPaths(req *http.Request) bool { - return strings.HasPrefix(req.URL.Path, "/public/") || +func ShouldTraceWithExceptions(req *http.Request) bool { + // Paths that don't need tracing spans applied to them because of the + // little value that would provide us + if strings.HasPrefix(req.URL.Path, "/public/") || req.URL.Path == "/robots.txt" || req.URL.Path == "/favicon.ico" || - req.URL.Path == "/api/health" + req.URL.Path == "/api/health" { + return false + } + + return true } -func TraceAllPaths(req *http.Request) bool { +func ShouldTraceAllPaths(req *http.Request) bool { return true } diff --git a/pkg/services/frontend/frontend_service.go b/pkg/services/frontend/frontend_service.go index 943509024d3..74776a1169e 100644 --- a/pkg/services/frontend/frontend_service.go +++ b/pkg/services/frontend/frontend_service.go @@ -134,7 +134,7 @@ func (s *frontendService) addMiddlewares(m *web.Mux) { loggermiddleware := loggermw.Provide(s.cfg, s.features) m.Use(requestmeta.SetupRequestMetadata()) - m.Use(middleware.RequestTracing(s.tracer, middleware.TraceAllPaths)) + m.Use(middleware.RequestTracing(s.tracer, middleware.ShouldTraceAllPaths)) m.Use(middleware.RequestMetrics(s.features, s.cfg, s.promRegister)) m.UseMiddleware(s.contextMiddleware()) From 9c8531b71b5e1db47ebeecf630dfd215460dac7e Mon Sep 17 00:00:00 2001 From: Daniele Stefano Ferru Date: Tue, 16 Dec 2025 11:20:04 +0100 Subject: [PATCH 10/21] Provisioning: Block Library Panel creation in provisioned folders (#114933) * WIP: Block Library Panel creation in provisioned folders * blocking patch - adding integration tests * checking code in tests * addressing comments, adding one more test --- pkg/services/libraryelements/api.go | 3 + pkg/services/libraryelements/database.go | 30 +++ pkg/services/libraryelements/model/model.go | 2 + pkg/tests/apis/provisioning/helper_test.go | 46 +++++ .../apis/provisioning/librarypanels_test.go | 175 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 pkg/tests/apis/provisioning/librarypanels_test.go diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 4895e7e6505..df51717756e 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -424,6 +424,9 @@ func (l *LibraryElementService) toLibraryElementError(err error, message string) if errors.Is(err, model.ErrLibraryElementUIDTooLong) { return response.Error(http.StatusBadRequest, model.ErrLibraryElementUIDTooLong.Error(), err) } + if errors.Is(err, model.ErrLibraryElementProvisionedFolder) { + return response.Error(http.StatusConflict, model.ErrLibraryElementProvisionedFolder.Error(), err) + } if err != nil && strings.Contains(err.Error(), "insufficient permissions") { return response.Error(http.StatusForbidden, err.Error(), err) } diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index a5b43fd54cb..2baa27ac1f8 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -125,6 +126,20 @@ func (l *LibraryElementService) CreateElement(c context.Context, signedInUser id } } + if cmd.FolderUID != nil { + f, err := l.folderService.Get(c, &folder.GetFolderQuery{ + OrgID: signedInUser.GetOrgID(), + UID: cmd.FolderUID, + SignedInUser: signedInUser, + }) + if err != nil { + return model.LibraryElementDTO{}, err + } + if f.ManagedBy == utils.ManagerKindRepo { + return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder + } + } + updatedModel := cmd.Model var err error if cmd.Kind == int64(model.PanelElement) { @@ -601,6 +616,21 @@ func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInU if err := l.requireSupportedElementKind(cmd.Kind); err != nil { return model.LibraryElementDTO{}, err } + + if cmd.FolderUID != nil { + f, err := l.folderService.Get(c, &folder.GetFolderQuery{ + OrgID: signedInUser.GetOrgID(), + UID: cmd.FolderUID, + SignedInUser: signedInUser, + }) + if err != nil { + return model.LibraryElementDTO{}, err + } + if f.ManagedBy == utils.ManagerKindRepo { + return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder + } + } + err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error { elementInDB, err := l.GetLibraryElement(c, signedInUser, session, uid) if err != nil { diff --git a/pkg/services/libraryelements/model/model.go b/pkg/services/libraryelements/model/model.go index 6e2bdfdcc41..4868bf50cbf 100644 --- a/pkg/services/libraryelements/model/model.go +++ b/pkg/services/libraryelements/model/model.go @@ -161,6 +161,8 @@ var ( ErrLibraryElementInvalidUID = errors.New("uid contains illegal characters") // errLibraryElementUIDTooLong is an error for when the uid of a library element is invalid ErrLibraryElementUIDTooLong = errors.New("uid too long, max 40 characters") + // ErrLibraryElementProvisionedFolder indicates that a library element cannot be created on a provisioned folder. + ErrLibraryElementProvisionedFolder = errors.New("resource type not supported in repository-managed folders") ) // Commands diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index f4687bdb01b..814c29ea11e 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -957,3 +957,49 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) { assert.Equal(collect, 0, len(list.Items), "repositories should be cleaned up") }, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests") } + +func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) { + return requestHelper(t, helper, http.MethodPost, path, body, user) +} + +func patchHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) { + return requestHelper(t, helper, http.MethodPatch, path, body, user) +} + +func requestHelper( + t *testing.T, + helper apis.K8sTestHelper, + method string, + path string, + body interface{}, + user apis.User, +) (map[string]interface{}, int, error) { + bodyJSON, err := json.Marshal(body) + require.NoError(t, err) + + resp := apis.DoRequest(&helper, apis.RequestParams{ + User: user, + Method: method, + Path: path, + Body: bodyJSON, + ContentType: "application/json", + }, &struct{}{}) + + if resp.Response.StatusCode != http.StatusOK { + res := map[string]interface{}{} + err := json.Unmarshal(resp.Body, &res) + if err != nil { + return nil, 0, fmt.Errorf("failed to unmarshal response JSON: %v", err) + } + + return res, resp.Response.StatusCode, fmt.Errorf("failure when making request: %s", resp.Response.Status) + } + + var result map[string]interface{} + err = json.Unmarshal(resp.Body, &result) + if err != nil { + return nil, 0, fmt.Errorf("failed to unmarshal response JSON: %v", err) + } + + return result, resp.Response.StatusCode, nil +} diff --git a/pkg/tests/apis/provisioning/librarypanels_test.go b/pkg/tests/apis/provisioning/librarypanels_test.go new file mode 100644 index 00000000000..47f87e7fb86 --- /dev/null +++ b/pkg/tests/apis/provisioning/librarypanels_test.go @@ -0,0 +1,175 @@ +package provisioning + +import ( + "fmt" + "net/http" + "testing" + "time" + + foldersV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +// We currently block the creation of library panels in provisioned folders. +func TestIntegrationLibraryPanels_ProvisionedFolders(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + helper.CreateRepo(t, TestRepo{ + Name: "test-repo", + Target: "folder", + ExpectedFolders: 1, + }) + + t.Run("should fail to create library element in provisioned folder", func(t *testing.T) { + folders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, folders.Items, 1) + + managedFolderName := folders.Items[0].GetName() + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Library Panel", + "folderUid": managedFolderName, + "model": map[string]interface{}{ + "type": "text", + "title": "Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, code, err := postHelper(t, *helper.K8sTestHelper, libraryElementURL, libraryElement, helper.Org1.Admin) + require.Error(t, err) + require.Equal(t, http.StatusConflict, code) + require.NotNil(t, libraryElementData) + require.Equal(t, "resource type not supported in repository-managed folders", libraryElementData["message"]) + }) + + t.Run("should fail to patch library element, moving it in a provisioned folder", func(t *testing.T) { + // Getting managed folder + folders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, folders.Items, 1) + managedFolderName := folders.Items[0].GetName() + + unmanagedFolder := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": foldersV1.FolderResourceInfo.GroupVersion().String(), + "kind": foldersV1.FolderResourceInfo.GroupVersionKind().Kind, + "metadata": map[string]interface{}{ + "generateName": "test-folder-", + }, + "spec": map[string]interface{}{ + "title": "Library Panel", + }, + }, + } + createdFolder, err := helper.Folders.Resource.Create(t.Context(), unmanagedFolder, metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdFolder) + + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Moved Library Panel", + "folderUid": createdFolder.GetName(), + "model": map[string]interface{}{ + "type": "text", + "title": "Moved Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, code, err := postHelper(t, *helper.K8sTestHelper, libraryElementURL, libraryElement, helper.Org1.Admin) + require.NoError(t, err) + require.Equal(t, http.StatusOK, code) + require.NotNil(t, libraryElementData) + + res := libraryElementData["result"].(map[string]interface{}) + helper.SetPermissions(helper.Org1.Admin, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{"library.panels:write"}, + Resource: "library.panels", + ResourceAttribute: "uid", + ResourceID: "*", + }, + }) + + // Patching libraryElement - changing folder to a managed one + updatedLibraryElement := map[string]interface{}{ + "kind": 1, + "version": res["version"], + "folderUid": managedFolderName, + } + patchLibraryElementURL := fmt.Sprintf("/api/library-elements/%f", +res["id"].(float64)) + newLibraryElement, code, err := patchHelper(t, *helper.K8sTestHelper, patchLibraryElementURL, updatedLibraryElement, helper.Org1.Admin) + require.Error(t, err) + require.Equal(t, http.StatusConflict, code) + require.NotNil(t, newLibraryElement) + require.Equal(t, "resource type not supported in repository-managed folders", newLibraryElement["message"]) + }) +} + +func TestIntegrationLibraryPanels_UnprovisionedFolders(t *testing.T) { + const repo = "test-repo" + helper := runGrafana(t) + helper.CreateRepo(t, TestRepo{ + Name: repo, + Target: "folder", + ExpectedFolders: 1, + }) + + t.Run("should create library element when folder is released", func(t *testing.T) { + folders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, folders.Items, 1) + managedFolderName := folders.Items[0].GetName() + require.Contains(t, folders.Items[0].GetAnnotations(), utils.AnnoKeyManagerKind, "folder should be managed") + require.Contains(t, folders.Items[0].GetAnnotations(), utils.AnnoKeyManagerIdentity, "folder should be managed") + + _, err = helper.Repositories.Resource.Patch(t.Context(), repo, types.JSONPatchType, []byte(`[ + { + "op": "replace", + "path": "/metadata/finalizers", + "value": ["cleanup", "release-orphan-resources"] + } + ]`), metav1.PatchOptions{}) + require.NoError(t, err, "should successfully patch finalizers") + + require.NoError(t, helper.Repositories.Resource.Delete(t.Context(), repo, metav1.DeleteOptions{})) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := helper.Repositories.Resource.Get(t.Context(), repo, metav1.GetOptions{}) + assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted") + }, time.Second*10, time.Millisecond*50, "repository should be deleted") + require.EventuallyWithT(t, func(collect *assert.CollectT) { + foundFolders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err, "can list values") + for _, v := range foundFolders.Items { + assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind) + assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity) + assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourcePath) + assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum) + } + }, time.Second*20, time.Millisecond*10, "Expected folders to be released") + + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Library Panel", + "folderUid": managedFolderName, + "model": map[string]interface{}{ + "type": "text", + "title": "Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, code, err := postHelper(t, *helper.K8sTestHelper, libraryElementURL, libraryElement, helper.Org1.Admin) + require.NoError(t, err) + require.Equal(t, http.StatusOK, code) + require.NotNil(t, libraryElementData) + }) +} From 1f4f2b4d7c6af8fb32fbba448a95d85e8886d632 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Tue, 16 Dec 2025 11:20:18 +0100 Subject: [PATCH 11/21] Plugins: Add PluginInsights UI (#111603) * Add getInsights endpoint, add new component PluginInsights * fix linting and add styles * add version option to insights request * Add plugininsights tests, remove console.logs * fix the insight items types * Add getting insights to all the mocks to fix the tests * remove deprecated lint package * Add theme colors, added tests to PluginDetailsPanel * Fix eslint error for plugin details page * Add pluginInsights feature toggle * change getInsights with version API call, resolve conflicts with main * fix typecheck and translation * updated UI * update registry go * fix translation * light css changes * remove duplicated feature toggle * fix the build * update plugin insights tests * fix typecheck * rudderstack added, feedback form added * fix translation --- eslint-suppressions.json | 5 - .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 14 ++ public/app/features/plugins/admin/api.ts | 16 ++ .../components/PluginDetailsPage.test.tsx | 2 + .../admin/components/PluginDetailsPage.tsx | 21 ++- .../components/PluginDetailsPanel.test.tsx | 71 +++++++- .../admin/components/PluginDetailsPanel.tsx | 9 +- .../admin/components/PluginInsights.test.tsx | 171 ++++++++++++++++++ .../admin/components/PluginInsights.tsx | 140 ++++++++++++++ .../plugins/admin/mocks/catalogPlugin.mock.ts | 2 + .../plugins/admin/mocks/mockHelpers.ts | 8 + .../features/plugins/admin/state/actions.ts | 20 +- .../app/features/plugins/admin/state/hooks.ts | 29 ++- .../features/plugins/admin/state/reducer.ts | 5 + public/app/features/plugins/admin/types.ts | 49 +++++ public/locales/en-US/grafana.json | 6 + 19 files changed, 566 insertions(+), 16 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginInsights.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginInsights.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ed74e615414..a34461f87d6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2910,11 +2910,6 @@ "count": 1 } }, - "public/app/features/plugins/admin/components/PluginDetailsPage.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/plugins/admin/helpers.ts": { "no-restricted-syntax": { "count": 2 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2aeb8c06ada..0eab199b022 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1185,6 +1185,11 @@ export interface FeatureToggles { */ onlyStoreActionSets?: boolean; /** + * Show insights for plugins in the plugin details page + * @default false + */ + pluginInsights?: boolean; + /** * Enables a new panel time settings drawer */ panelTimeSettings?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 15eadb55175..d898492e918 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1953,6 +1953,14 @@ var ( Owner: identityAccessTeam, Expression: "true", }, + { + Name: "pluginInsights", + Description: "Show insights for plugins in the plugin details page", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaPluginsPlatformSquad, + Expression: "false", + }, { Name: "panelTimeSettings", Description: "Enables a new panel time settings drawer", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f470a8b9c7a..51bb242f1d9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -265,6 +265,7 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false +pluginInsights,experimental,@grafana/plugins-platform-backend,false,false,true panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 95e1f0ab34d..b768dc2f761 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2667,6 +2667,20 @@ "expression": "false" } }, + { + "metadata": { + "name": "pluginInsights", + "resourceVersion": "1761300628147", + "creationTimestamp": "2025-10-24T10:10:28Z" + }, + "spec": { + "description": "Show insights for plugins in the plugin details page", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "pluginInstallAPISync", diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 74a072ba054..aa5bc32f183 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -8,6 +8,7 @@ import { LocalPlugin, RemotePlugin, CatalogPluginDetails, + CatalogPluginInsights, Version, PluginVersion, InstancePlugin, @@ -47,6 +48,21 @@ export async function getPluginDetails(id: string): Promise { + if (!version) { + throw new Error('Version is required'); + } + try { + const insights = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions/${version}/insights`); + return insights; + } catch (error) { + if (isFetchError(error)) { + error.isHandled = true; + } + throw error; + } +} + export async function getRemotePlugins(): Promise { try { const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, { diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index da4eef2f0d4..0ffc93f8f77 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -62,10 +62,12 @@ const plugin: CatalogPlugin = { angularDetected: false, isFullyInstalled: true, accessControl: {}, + insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] }, }; jest.mock('../state/hooks', () => ({ useGetSingle: jest.fn(), + useGetPluginInsights: jest.fn(), useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }), useFetchDetailsStatus: () => ({ isLoading: false }), useIsRemotePluginsAvailable: () => false, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index 0e651a8e4bf..b135321e558 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -16,11 +16,19 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel'; import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; -import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; +import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks'; import { PluginTabIds } from '../types'; import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning'; +function isPluginTabId(value: string | null): value is PluginTabIds { + if (!value) { + return false; + } + const validIds: string[] = Object.values(PluginTabIds); + return validIds.includes(value); +} + export type Props = { // The ID of the plugin pluginId: string; @@ -49,12 +57,13 @@ export function PluginDetailsPage({ }; const queryParams = new URLSearchParams(location.search); const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance + useGetPluginInsights(pluginId, plugin?.isInstalled ? plugin?.installedVersion : plugin?.latestVersion); + const isNarrowScreen = useMedia('(max-width: 600px)'); - const { navModel, activePageId } = usePluginDetailsTabs( - plugin, - queryParams.get('page') as PluginTabIds, - isNarrowScreen - ); + const pageParam = queryParams.get('page'); + const pageId = pageParam && isPluginTabId(pageParam) ? pageParam : undefined; + const { navModel, activePageId } = usePluginDetailsTabs(plugin, pageId, isNarrowScreen); + const { actions, info, subtitle } = usePluginPageExtensions(plugin); const { isLoading: isFetchLoading } = useFetchStatus(); const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx index eade37f559c..20787099842 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx @@ -1,11 +1,23 @@ +import userEvent from '@testing-library/user-event'; import { render, screen } from 'test/test-utils'; import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; -import { CatalogPlugin } from '../types'; +import { CatalogPlugin, SCORE_LEVELS } from '../types'; import { PluginDetailsPanel } from './PluginDetailsPanel'; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + featureToggles: { + pluginInsights: false, + }, + }, +})); + const mockPlugin: CatalogPlugin = { description: 'Test plugin description', downloads: 1000, @@ -185,4 +197,61 @@ describe('PluginDetailsPanel', () => { expect(regularLinks).toContainElement(raiseIssueLink); expect(regularLinks).not.toContainElement(websiteLink); }); + + it('should render plugin insights when plugin has insights', async () => { + config.featureToggles.pluginInsights = true; + const pluginWithInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + level: 'ok' as const, + }, + ], + }, + ], + }, + }; + render(); + expect(screen.getByTestId('plugin-insights-container')).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + expect(screen.queryByText('Security')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + }); + + it('should not render plugin insights when plugin has no insights', () => { + const pluginWithoutInsights = { + ...mockPlugin, + insights: undefined, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); + + it('should not render plugin insights when insights array is empty', () => { + const pluginWithEmptyInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [], + }, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 00211b61c6e..aa8b6c792ef 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/internal'; import { Stack, @@ -22,6 +22,8 @@ import { formatDate } from 'app/core/internationalization/dates'; import { CatalogPlugin } from '../types'; +import { PluginInsights } from './PluginInsights'; + type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string }; export function PluginDetailsPanel(props: Props): React.ReactElement | null { @@ -69,6 +71,11 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null { return ( <> + {config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && ( + + + + )} {pluginExtentionsInfo.map((infoItem, index) => { diff --git a/public/app/features/plugins/admin/components/PluginInsights.test.tsx b/public/app/features/plugins/admin/components/PluginInsights.test.tsx new file mode 100644 index 00000000000..efd064c7172 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.test.tsx @@ -0,0 +1,171 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test/test-utils'; + +import { CatalogPluginInsights, InsightLevel, SCORE_LEVELS } from '../types'; + +import { PluginInsights } from './PluginInsights'; + +const mockPluginInsights: CatalogPluginInsights = { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + description: 'Plugin signature is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'trackingscripts', + name: 'No unsafe JavaScript detected', + level: 'good' as InsightLevel, + }, + ], + }, + { + name: 'quality', + scoreValue: 60, + scoreLevel: SCORE_LEVELS.FAIR, + items: [ + { + id: 'metadatavalid', + name: 'Metadata is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'code-rules', + name: 'Missing code rules', + description: 'Plugin lacks comprehensive code rules', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +const mockPluginInsightsWithPoorLevel: CatalogPluginInsights = { + id: 3, + name: 'test-plugin-poor', + version: '0.8.0', + insights: [ + { + name: 'quality', + scoreValue: 35, + scoreLevel: SCORE_LEVELS.POOR, + items: [ + { + id: 'legacy-platform', + name: 'Quality issues detected', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +describe('PluginInsights', () => { + it('should render plugin insights section', () => { + render(); + const insightsSection = screen.getByTestId('plugin-insights-container'); + expect(insightsSection).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + }); + + it('should render all insight categories with test ids', () => { + render(); + expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument(); + }); + + it('should render category names with test ids', () => { + render(); + const securityCategory = screen.getByTestId('plugin-insight-security'); + const qualityCategory = screen.getByTestId('plugin-insight-quality'); + + expect(securityCategory).toBeInTheDocument(); + expect(securityCategory).toHaveTextContent('Security'); + expect(qualityCategory).toBeInTheDocument(); + expect(qualityCategory).toHaveTextContent('Quality'); + }); + + it('should render individual insight items with test ids', async () => { + render(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-trackingscripts')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByTestId('plugin-insight-item-metadatavalid')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-code-rules')).toBeInTheDocument(); + }); + + it('should display correct icons for Excellent score level', () => { + render(); + + const securityCategory = screen.getByTestId('plugin-insight-security'); + const securityIcon = securityCategory.querySelector('[data-testid="excellent-icon"]'); + expect(securityIcon).toBeInTheDocument(); + }); + + it('should display correct icons for Poor score levels', () => { + // Test Poor level - should show exclamation-triangle + render(); + const poorCategory = screen.getByTestId('plugin-insight-quality'); + const poorIcon = poorCategory.querySelector('[data-testid="poor-icon"]'); + expect(poorIcon).toBeInTheDocument(); + }); + + it('should handle multiple items with different insight levels', async () => { + const multiLevelInsights: CatalogPluginInsights = { + id: 5, + name: 'multi-level-plugin', + version: '2.0.0', + insights: [ + { + name: 'quality', + scoreValue: 75, + scoreLevel: SCORE_LEVELS.GOOD, + items: [ + { + id: 'code-rules', + name: 'Info level item', + level: 'info' as InsightLevel, + }, + { + id: 'sdk-usage', + name: 'OK level item', + level: 'ok' as InsightLevel, + }, + { + id: 'jsMap', + name: 'Good level item', + level: 'good' as InsightLevel, + }, + { + id: 'gosec', + name: 'Warning level item', + level: 'warning' as InsightLevel, + }, + { + id: 'legacy-builder', + name: 'Danger level item', + level: 'danger' as InsightLevel, + }, + ], + }, + ], + }; + render(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByText('Info level item')).toBeInTheDocument(); + expect(screen.getByText('OK level item')).toBeInTheDocument(); + expect(screen.getByText('Good level item')).toBeInTheDocument(); + expect(screen.getByText('Warning level item')).toBeInTheDocument(); + expect(screen.getByText('Danger level item')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginInsights.tsx b/public/app/features/plugins/admin/components/PluginInsights.tsx new file mode 100644 index 00000000000..805bcf926bf --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.tsx @@ -0,0 +1,140 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { Stack, Text, TextLink, CollapsableSection, Tooltip, Icon, useStyles2, useTheme2 } from '@grafana/ui'; + +import { CatalogPluginInsights } from '../types'; + +type Props = { pluginInsights: CatalogPluginInsights | undefined }; + +const PLUGINS_INSIGHTS_OPENED_EVENT_NAME = 'plugins_insights_opened'; + +export function PluginInsights(props: Props): React.ReactElement | null { + const { pluginInsights } = props; + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const [openInsights, setOpenInsights] = useState>({}); + + const handleInsightToggle = (insightName: string, isOpen: boolean) => { + if (isOpen) { + reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName }); + } + setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen })); + }; + + const tooltipInfo = ( + + + + + + All relevant signals are present and verified + + + + + + + + One or more signals are missing or need attention + + + +
+ + + Do you find Plugin Insights usefull? Please share your feedback{' '} + + here + + . + + +
+ ); + + return ( + <> + + + + Plugin insights + + + + + + {pluginInsights?.insights.map((insightItem, index) => { + return ( + + handleInsightToggle(insightItem.name, isOpen)} + label={ + + {insightItem.scoreLevel === 'Excellent' ? ( + + ) : ( + + )} + + {capitalize(insightItem.name)} + + + } + contentClassName={styles.pluginInsightsItems} + > + + {insightItem.items.map((item, idx) => ( + + + {item.level === 'good' ? ( + + ) : ( + + )} + + + {item.name} + + + ))} + + + + ); + })} + + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + pluginVersionDetails: css({ wordBreak: 'break-word' }), + pluginInsightsItems: css({ marginLeft: '26px', paddingTop: '0 !important' }), + pluginInsightsTooltipSeparator: css({ + border: 'none', + borderTop: `1px solid ${theme.colors.border.medium}`, + margin: `${theme.spacing(1)} 0`, + }), + }; +}; diff --git a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts index 9ced4f20a84..3625b687f7b 100644 --- a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts @@ -34,6 +34,7 @@ export default { updatedAt: '2021-08-25T15:03:49.000Z', version: '4.2.2', error: undefined, + insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], @@ -381,6 +382,7 @@ export const datasourcePlugin = { angularDetected: false, isFullyInstalled: true, latestVersion: '1.20.0', + insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], diff --git a/public/app/features/plugins/admin/mocks/mockHelpers.ts b/public/app/features/plugins/admin/mocks/mockHelpers.ts index 6034e8860e9..d6e04186f77 100644 --- a/public/app/features/plugins/admin/mocks/mockHelpers.ts +++ b/public/app/features/plugins/admin/mocks/mockHelpers.ts @@ -31,6 +31,9 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState 'plugins/fetchDetails': { status: RequestStatus.Fulfilled, }, + 'plugins/fetchPluginInsights': { + status: RequestStatus.Fulfilled, + }, }, // Backward compatibility plugins: [], @@ -75,6 +78,11 @@ export const mockPluginApis = ({ return Promise.resolve({ items: versions }); } + // Mock plugin insights - return empty insights to avoid API call errors + if (path.includes('/insights')) { + return Promise.resolve({ id: 1, name: '', version: '', insights: [] }); + } + // Mock local plugin settings (installed) if necessary if (local && path === `${API_ROOT}/${local.id}/settings`) { return Promise.resolve(local); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 6be68111dd5..e9cf2d9d40d 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -13,6 +13,7 @@ import { getPluginErrors, getLocalPlugins, getPluginDetails, + getPluginInsights, installPlugin, uninstallPlugin, getInstancePlugins, @@ -165,6 +166,22 @@ export const fetchDetails = createAsyncThunk, stri } ); +export const fetchPluginInsights = createAsyncThunk, { id: string; version?: string }>( + `${STATE_PREFIX}/fetchPluginInsights`, + async ({ id, version }, thunkApi) => { + try { + const insights = await getPluginInsights(id, version); + + return { + id, + changes: { insights }, + }; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } + } +); + export const addPlugins = createAction(`${STATE_PREFIX}/addPlugins`); // 1. gets remote equivalents from the store (if there are any) @@ -265,7 +282,8 @@ export const panelPluginLoaded = createAction(`${STATE_PREFIX}/pane // TODO export const loadPanelPlugin = (id: string): ThunkResult> => { return async (dispatch, getStore) => { - let plugin = getStore().plugins.panels[id]; + const state = getStore(); + let plugin = state.plugins.panels[id]; if (!plugin) { plugin = await importPanelPlugin(id); diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 2185ec99465..6eb47d7e1aa 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -6,7 +6,16 @@ import { useDispatch, useSelector } from 'app/types/store'; import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers'; import { CatalogPlugin, PluginStatus } from '../types'; -import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions'; +import { + fetchAll, + fetchDetails, + fetchRemotePlugins, + install, + uninstall, + fetchAllLocal, + unsetInstall, + fetchPluginInsights, +} from './actions'; import { selectPlugins, selectById, @@ -44,13 +53,18 @@ export const useGetUpdatable = () => { }; }; -export const useGetSingle = (id: string): CatalogPlugin | undefined => { +export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); return useSelector((state) => selectById(state, id)); }; +export const useGetPluginInsights = (id: string, version: string | undefined): CatalogPlugin | undefined => { + useFetchPluginInsights(id, version); + return useSelector((state) => selectById(state, id)); +}; + export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => { useFetchAllLocal(); return useSelector((state) => selectById(state, id)); @@ -153,6 +167,17 @@ export const useFetchDetails = (id: string) => { }, [plugin]); // eslint-disable-line }; +export const useFetchPluginInsights = (id: string, version: string | undefined) => { + const dispatch = useDispatch(); + const plugin = useSelector((state) => selectById(state, id)); + const isNotFetching = !useSelector(selectIsRequestPending(fetchPluginInsights.typePrefix)); + const shouldFetch = isNotFetching && plugin && !plugin.insights && version; + + useEffect(() => { + shouldFetch && dispatch(fetchPluginInsights({ id, version })); + }, [plugin, version]); // eslint-disable-line +}; + export const useFetchDetailsLazy = () => { const dispatch = useDispatch(); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index f2414a31405..e3d5bec5427 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -7,6 +7,7 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types'; import { fetchDetails, + fetchPluginInsights, install, uninstall, loadPluginDashboards, @@ -63,6 +64,10 @@ const slice = createSlice({ .addCase(fetchDetails.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); }) + // Fetch Plugin Insights + .addCase(fetchPluginInsights.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) // Install .addCase(install.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 3cc66bba0b9..df4114101b4 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -55,6 +55,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata { updatedAt: string; installedVersion?: string; details?: CatalogPluginDetails; + insights?: CatalogPluginInsights; error?: PluginErrorCode; angularDetected?: boolean; // instance plugins may not be fully installed, which means a new instance @@ -90,6 +91,54 @@ export interface CatalogPluginDetails { screenshots?: Screenshots[] | null; } +export type InsightLevel = 'ok' | 'warning' | 'danger' | 'good' | 'info'; + +export const SCORE_LEVELS = { + EXCELLENT: 'Excellent', + GOOD: 'Good', + FAIR: 'Fair', + POOR: 'Poor', + CRITICAL: 'Critical', +} as const; + +export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS]; + +export const INSIGHT_CATEGORIES = { + SECURITY: 'security', + QUALITY: 'quality', + PERFORMANCE: 'performance', +} as const; + +export const INSIGHT_LEVELS = { + GOOD: 'good', + OK: 'ok', + WARNING: 'warning', + DANGER: 'danger', + INFO: 'info', +} as const; + +export interface InsightItem { + id: string; + name: string; + description?: string; + level: InsightLevel; + link?: string; +} + +export interface InsightCategory { + name: string; + items: InsightItem[]; + scoreValue: number; + scoreLevel: ScoreLevel; +} + +export interface CatalogPluginInsights { + id: number; + name: string; + version: string; + insights: InsightCategory[]; +} + export interface CatalogPluginInfo { logos: { large: string; small: string }; keywords: string[]; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 96a20fc1e3f..b3b6ce2c8db 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11413,6 +11413,12 @@ "latestReleaseDate": "Latest release date:", "latestVersion": "Latest Version", "license": "License", + "moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here.", + "pluginInsights": { + "header": "Plugin insights" + }, + "pluginInsightsSuccessTooltip": "All relevant signals are present and verified", + "pluginInsightsWarningTooltip": "One or more signals are missing or need attention", "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern", "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.", From a4eb98b4edc9903f4b202052eacd69958036133c Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Tue, 16 Dec 2025 11:33:18 +0100 Subject: [PATCH 12/21] Advisor: RBAC revamp (#115151) Co-authored-by: Todd Treece --- apps/advisor/go.mod | 20 ++- apps/advisor/go.sum | 29 ++++ apps/advisor/pkg/app/app.go | 48 ++++++ apps/advisor/pkg/app/authorizer.go | 47 ------ apps/advisor/pkg/app/authorizer_test.go | 91 ----------- pkg/apimachinery/identity/context.go | 1 + pkg/registry/apps/advisor/accesscontrol.go | 150 ++++++++++++++++++ pkg/registry/apps/advisor/register.go | 43 ++--- pkg/server/wire_gen.go | 4 +- pkg/services/accesscontrol/permreg/permreg.go | 3 + pkg/services/authz/rbac/mapper.go | 5 + 11 files changed, 273 insertions(+), 168 deletions(-) delete mode 100644 apps/advisor/pkg/app/authorizer.go delete mode 100644 apps/advisor/pkg/app/authorizer_test.go create mode 100644 pkg/registry/apps/advisor/accesscontrol.go diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 79e5242ba5e..1dc4d93b4f5 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -15,6 +15,7 @@ require ( github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 + k8s.io/client-go v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -43,6 +44,7 @@ replace github.com/grafana/grafana/apps/plugins => ../plugins replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 require ( + cel.dev/expr v0.24.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -55,6 +57,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect @@ -85,6 +88,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -101,6 +105,7 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect @@ -144,6 +149,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -162,6 +168,7 @@ require ( github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -176,6 +183,7 @@ require ( github.com/hashicorp/memberlist v0.5.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect github.com/jessevdk/go-flags v1.6.1 // indirect github.com/jmespath-community/go-jmespath v1.1.1 // indirect @@ -248,7 +256,9 @@ require ( github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect @@ -256,6 +266,9 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect + go.etcd.io/etcd/api/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/v3 v3.6.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect @@ -274,6 +287,8 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect @@ -297,23 +312,26 @@ require ( google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/telebot.v3 v3.3.8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.2 // indirect k8s.io/apiextensions-apiserver v0.34.2 // indirect - k8s.io/client-go v0.34.2 // indirect k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kms v0.34.2 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.40.1 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 30238124dd4..4c5843a2ee2 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -282,6 +282,7 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -406,6 +407,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= @@ -606,6 +609,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= @@ -749,6 +754,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -979,6 +986,7 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= @@ -996,6 +1004,7 @@ github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= @@ -1010,6 +1019,7 @@ github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57J github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= @@ -1036,6 +1046,7 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -1058,6 +1069,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1071,6 +1084,7 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= @@ -1096,6 +1110,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -1112,6 +1127,8 @@ github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+Xq github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -1129,6 +1146,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1139,6 +1158,8 @@ github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= +go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= +go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/api/v3 v3.6.4 h1:7F6N7toCKcV72QmoUKa23yYLiiljMrT4xCeBL9BmXdo= go.etcd.io/etcd/api/v3 v3.6.4/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk= @@ -1149,6 +1170,12 @@ go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+ go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A= go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo= +go.etcd.io/etcd/pkg/v3 v3.6.4 h1:fy8bmXIec1Q35/jRZ0KOes8vuFxbvdN0aAFqmEfJZWA= +go.etcd.io/etcd/pkg/v3 v3.6.4/go.mod h1:kKcYWP8gHuBRcteyv6MXWSN0+bVMnfgqiHueIZnKMtE= +go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= +go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= @@ -1301,6 +1328,7 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1712,6 +1740,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/apps/advisor/pkg/app/app.go b/apps/advisor/pkg/app/app.go index 9c1dd3c0f98..114b1fbabce 100644 --- a/apps/advisor/pkg/app/app.go +++ b/apps/advisor/pkg/app/app.go @@ -8,18 +8,24 @@ import ( "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/k8s" + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/operator" "github.com/grafana/grafana-app-sdk/resource" "github.com/grafana/grafana-app-sdk/simple" + advisorapi "github.com/grafana/grafana/apps/advisor/pkg/apis" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" "github.com/grafana/grafana/apps/advisor/pkg/app/checkscheduler" "github.com/grafana/grafana/apps/advisor/pkg/app/checktyperegisterer" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/rest" ) func New(cfg app.Config) (app.App, error) { @@ -188,3 +194,45 @@ func GetKinds() map[schema.GroupVersion][]resource.Kind { }, } } + +func ProvideAppInstaller( + authorizer authorizer.Authorizer, + checkRegistry checkregistry.CheckService, + cfg *setting.Cfg, + orgService org.Service, +) (*AdvisorAppInstaller, error) { + provider := simple.NewAppProvider(advisorapi.LocalManifest(), nil, New) + pluginConfig := cfg.PluginSettings["grafana-advisor-app"] + specificConfig := checkregistry.AdvisorAppConfig{ + CheckRegistry: checkRegistry, + PluginConfig: pluginConfig, + StackID: cfg.StackID, + OrgService: orgService, + } + appCfg := app.Config{ + KubeConfig: rest.Config{}, + ManifestData: *advisorapi.LocalManifest().ManifestData, + SpecificConfig: specificConfig, + } + + defaultInstaller, err := appsdkapiserver.NewDefaultAppInstaller(provider, appCfg, advisorapi.NewGoTypeAssociator()) + if err != nil { + return nil, err + } + + installer := &AdvisorAppInstaller{ + AppInstaller: defaultInstaller, + authorizer: authorizer, + } + + return installer, nil +} + +type AdvisorAppInstaller struct { + appsdkapiserver.AppInstaller + authorizer authorizer.Authorizer +} + +func (a *AdvisorAppInstaller) GetAuthorizer() authorizer.Authorizer { + return a.authorizer +} diff --git a/apps/advisor/pkg/app/authorizer.go b/apps/advisor/pkg/app/authorizer.go deleted file mode 100644 index 576773330e3..00000000000 --- a/apps/advisor/pkg/app/authorizer.go +++ /dev/null @@ -1,47 +0,0 @@ -package app - -import ( - "context" - - claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc(func( - ctx context.Context, attr authorizer.Attributes, - ) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - // Check for service identity - if identity.IsServiceIdentity(ctx) { - return authorizer.DecisionAllow, "", nil - } - - // Check for access policy identity - info, ok := claims.AuthInfoFrom(ctx) - if ok && claims.IsIdentityType(info.GetIdentityType(), claims.TypeAccessPolicy) { - // For access policy identities, we need to use ResourceAuthorizer - // This requires an AccessClient, which should be provided by the API server - // For now, we'll use the default ResourceAuthorizer from the API server - // This will be set up by the API server's authorization chain - return authorizer.DecisionNoOpinion, "", nil - } - - // For regular Grafana users, check if they are admin - u, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - // check if is admin - if u.HasRole(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - - return authorizer.DecisionDeny, "forbidden", nil - }) -} diff --git a/apps/advisor/pkg/app/authorizer_test.go b/apps/advisor/pkg/app/authorizer_test.go deleted file mode 100644 index de8c0fad2db..00000000000 --- a/apps/advisor/pkg/app/authorizer_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package app - -import ( - "context" - "testing" - - claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/stretchr/testify/assert" - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func TestGetAuthorizer(t *testing.T) { - tests := []struct { - name string - ctx context.Context - attr authorizer.Attributes - expectedDecision authorizer.Decision - expectedReason string - expectedErr error - }{ - { - name: "non-resource request", - ctx: context.TODO(), - attr: &mockAttributes{resourceRequest: false}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user is admin", - ctx: identity.WithRequester(context.TODO(), &mockUser{isGrafanaAdmin: true}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionAllow, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user is not admin", - ctx: identity.WithRequester(context.TODO(), &mockUser{isGrafanaAdmin: false}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionDeny, - expectedReason: "forbidden", - expectedErr: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := GetAuthorizer() - decision, reason, err := auth.Authorize(tt.ctx, tt.attr) - assert.Equal(t, tt.expectedDecision, decision) - assert.Equal(t, tt.expectedReason, reason) - assert.Equal(t, tt.expectedErr, err) - }) - } -} - -type mockAttributes struct { - authorizer.Attributes - resourceRequest bool -} - -func (m *mockAttributes) IsResourceRequest() bool { - return m.resourceRequest -} - -// Implement other methods of authorizer.Attributes as needed - -type mockUser struct { - identity.Requester - isGrafanaAdmin bool -} - -func (m *mockUser) GetIsGrafanaAdmin() bool { - return m.isGrafanaAdmin -} - -func (m *mockUser) HasRole(role identity.RoleType) bool { - return role == identity.RoleAdmin && m.isGrafanaAdmin -} - -func (m *mockUser) GetUID() string { - return "test-uid" -} - -func (m *mockUser) GetIdentityType() claims.IdentityType { - return claims.TypeUser -} - -// Implement other methods of identity.Requester as needed diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 4362c048b0a..c66b4188cbf 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -162,6 +162,7 @@ var serviceIdentityTokenPermissions = []string{ "collections.grafana.app:*", // user stars "plugins.grafana.app:*", "historian.alerting.grafana.app:*", + "advisor.grafana.app:*", // Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions. "secret.grafana.app/securevalues:decrypt", diff --git a/pkg/registry/apps/advisor/accesscontrol.go b/pkg/registry/apps/advisor/accesscontrol.go new file mode 100644 index 00000000000..2b59e55aa4e --- /dev/null +++ b/pkg/registry/apps/advisor/accesscontrol.go @@ -0,0 +1,150 @@ +package advisor + +import ( + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/org" +) + +const ( + // Check + ActionAdvisorCheckCreate = "advisor.checks:create" // CREATE. + ActionAdvisorCheckWrite = "advisor.checks:write" // UPDATE. + ActionAdvisorCheckRead = "advisor.checks:read" // GET + LIST. + ActionAdvisorCheckDelete = "advisor.checks:delete" // DELETE. + + // CheckTypes + ActionAdvisorCheckTypesCreate = "advisor.checktypes:create" // CREATE. + ActionAdvisorCheckTypesWrite = "advisor.checktypes:write" // UPDATE. + ActionAdvisorCheckTypesRead = "advisor.checktypes:read" // GET + LIST. + ActionAdvisorCheckTypesDelete = "advisor.checktypes:delete" // DELETE. + + // Register + ActionAdvisorRegisterCreate = "advisor.register:create" // CREATE (register check types). +) + +var ( + ScopeProviderAdvisorCheck = accesscontrol.NewScopeProvider("advisor.checks") + ScopeProviderAdvisorCheckTypes = accesscontrol.NewScopeProvider("advisor.checktypes") + ScopeProviderAdvisorRegister = accesscontrol.NewScopeProvider("advisor.register") + + ScopeAllAdvisorCheck = ScopeProviderAdvisorCheck.GetResourceAllScope() + ScopeAllAdvisorCheckTypes = ScopeProviderAdvisorCheckTypes.GetResourceAllScope() + ScopeAllAdvisorRegister = ScopeProviderAdvisorRegister.GetResourceAllScope() +) + +func registerAccessControlRoles(service accesscontrol.Service) error { + // Check + checkReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:advisor.checks:reader", + DisplayName: "Advisor Check Reader", + Description: "Read and list advisor checks.", + Group: "Advisor", + Permissions: []accesscontrol.Permission{ + { + Action: ActionAdvisorCheckRead, + Scope: ScopeAllAdvisorCheck, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + checkWriter := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:advisor.checks:writer", + DisplayName: "Advisor Check Writer", + Description: "Create, update and delete advisor checks.", + Group: "Advisor", + Permissions: []accesscontrol.Permission{ + { + Action: ActionAdvisorCheckCreate, + Scope: ScopeAllAdvisorCheck, + }, + { + Action: ActionAdvisorCheckRead, + Scope: ScopeAllAdvisorCheck, + }, + { + Action: ActionAdvisorCheckWrite, + Scope: ScopeAllAdvisorCheck, + }, + { + Action: ActionAdvisorCheckDelete, + Scope: ScopeAllAdvisorCheck, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + // CheckTypes + checkTypesReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:advisor.checktypes:reader", + DisplayName: "Advisor Check Types Reader", + Description: "Read and list advisor check types.", + Group: "Advisor", + Permissions: []accesscontrol.Permission{ + { + Action: ActionAdvisorCheckTypesRead, + Scope: ScopeAllAdvisorCheckTypes, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + checkTypesWriter := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:advisor.checktypes:writer", + DisplayName: "Advisor Check Types Writer", + Description: "Create, update and delete advisor check types.", + Group: "Advisor", + Permissions: []accesscontrol.Permission{ + { + Action: ActionAdvisorCheckTypesCreate, + Scope: ScopeAllAdvisorCheckTypes, + }, + { + Action: ActionAdvisorCheckTypesRead, + Scope: ScopeAllAdvisorCheckTypes, + }, + { + Action: ActionAdvisorCheckTypesWrite, + Scope: ScopeAllAdvisorCheckTypes, + }, + { + Action: ActionAdvisorCheckTypesDelete, + Scope: ScopeAllAdvisorCheckTypes, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + // Register + registerWriter := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:advisor.register:writer", + DisplayName: "Advisor Register Writer", + Description: "Register default advisor check types.", + Group: "Advisor", + Permissions: []accesscontrol.Permission{ + { + Action: ActionAdvisorRegisterCreate, + Scope: ScopeAllAdvisorRegister, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + return service.DeclareFixedRoles( + checkReader, + checkWriter, + checkTypesReader, + checkTypesWriter, + registerWriter, + ) +} diff --git a/pkg/registry/apps/advisor/register.go b/pkg/registry/apps/advisor/register.go index 2cafb630803..31ad7d7e7d5 100644 --- a/pkg/registry/apps/advisor/register.go +++ b/pkg/registry/apps/advisor/register.go @@ -1,17 +1,17 @@ package advisor import ( - "github.com/grafana/grafana-app-sdk/app" + "fmt" + + authlib "github.com/grafana/authlib/types" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" - "github.com/grafana/grafana-app-sdk/simple" - advisorapi "github.com/grafana/grafana/apps/advisor/pkg/apis" advisorapp "github.com/grafana/grafana/apps/advisor/pkg/app" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" - "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/client-go/rest" ) var ( @@ -20,37 +20,26 @@ var ( ) type AdvisorAppInstaller struct { - appsdkapiserver.AppInstaller -} - -// GetAuthorizer returns the authorizer for the plugins app. -func (a *AdvisorAppInstaller) GetAuthorizer() authorizer.Authorizer { - return advisorapp.GetAuthorizer() + *advisorapp.AdvisorAppInstaller } func ProvideAppInstaller( + accessControlService accesscontrol.Service, + accessClient authlib.AccessClient, checkRegistry checkregistry.CheckService, cfg *setting.Cfg, orgService org.Service, ) (*AdvisorAppInstaller, error) { - provider := simple.NewAppProvider(advisorapi.LocalManifest(), nil, advisorapp.New) - pluginConfig := cfg.PluginSettings["grafana-advisor-app"] - specificConfig := checkregistry.AdvisorAppConfig{ - CheckRegistry: checkRegistry, - PluginConfig: pluginConfig, - StackID: cfg.StackID, - OrgService: orgService, + if err := registerAccessControlRoles(accessControlService); err != nil { + return nil, fmt.Errorf("registering access control roles: %w", err) } - appCfg := app.Config{ - KubeConfig: rest.Config{}, - ManifestData: *advisorapi.LocalManifest().ManifestData, - SpecificConfig: specificConfig, - } - installer := &AdvisorAppInstaller{} - i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appCfg, advisorapi.NewGoTypeAssociator()) + + authorizer := grafanaauthorizer.NewResourceAuthorizer(accessClient) + i, err := advisorapp.ProvideAppInstaller(authorizer, checkRegistry, cfg, orgService) if err != nil { return nil, err } - installer.AppInstaller = i - return installer, nil + return &AdvisorAppInstaller{ + AdvisorAppInstaller: i, + }, nil } diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 6e068337a29..5ed04adf12b 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -819,7 +819,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } checkregistryService := checkregistry.ProvideService(service15, pluginstoreService, plugincontextProvider, middlewareHandler, plugincheckerService, repoManager, preinstallImpl, managedpluginsNoop, noop, ssosettingsimplService, cfg, pluginerrsStore) - advisorAppInstaller, err := advisor2.ProvideAppInstaller(checkregistryService, cfg, orgService) + advisorAppInstaller, err := advisor2.ProvideAppInstaller(acimplService, accessClient, checkregistryService, cfg, orgService) if err != nil { return nil, err } @@ -1480,7 +1480,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } checkregistryService := checkregistry.ProvideService(service15, pluginstoreService, plugincontextProvider, middlewareHandler, plugincheckerService, repoManager, preinstallImpl, managedpluginsNoop, noop, ssosettingsimplService, cfg, pluginerrsStore) - advisorAppInstaller, err := advisor2.ProvideAppInstaller(checkregistryService, cfg, orgService) + advisorAppInstaller, err := advisor2.ProvideAppInstaller(acimplService, accessClient, checkregistryService, cfg, orgService) if err != nil { return nil, err } diff --git a/pkg/services/accesscontrol/permreg/permreg.go b/pkg/services/accesscontrol/permreg/permreg.go index e975681ff03..cae7b701cf0 100644 --- a/pkg/services/accesscontrol/permreg/permreg.go +++ b/pkg/services/accesscontrol/permreg/permreg.go @@ -86,6 +86,9 @@ func newPermissionRegistry() *permissionRegistry { "plugins": "plugins:id:", "plugins.plugins": "plugins.plugins:uid:", "plugins.metas": "plugins.metas:uid:", + "advisor.checks": "advisor.checks:uid:", + "advisor.checktypes": "advisor.checktypes:uid:", + "advisor.register": "advisor.register:uid:", "provisioners": "provisioners:", "reports": "reports:id:", "permissions": "permissions:type:", diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index d50ca050cdb..9444d35d0ae 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -301,6 +301,11 @@ func NewMapperRegistry() MapperRegistry { "plugins": newResourceTranslation("plugins.plugins", "uid", false, nil), "metas": newResourceTranslation("plugins.metas", "uid", false, nil), }, + "advisor.grafana.app": { + "checks": newResourceTranslation("advisor.checks", "uid", false, nil), + "checktypes": newResourceTranslation("advisor.checktypes", "uid", false, nil), + "register": newResourceTranslation("advisor.register", "uid", false, nil), + }, }) return mapper From 6ffb805d459d48dca32a602b8eacc6df1081566c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 16 Dec 2025 11:43:59 +0100 Subject: [PATCH 13/21] Dashboard: Hide sidebar in kiosk mode (#115387) * Dashboard: Hide sidebar in kiosk mode * Fix kiosk url sync issues * Fixes * fixes * Update --- eslint-suppressions.json | 2 +- .../src/components/Dropdown/Dropdown.tsx | 8 +- .../edit-pane/DashboardEditPaneSplitter.tsx | 115 ++++++++++++------ .../dashboard-scene/scene/DashboardScene.tsx | 4 +- .../scene/DashboardSceneUrlSync.test.ts | 20 --- .../scene/DashboardSceneUrlSync.ts | 10 +- .../actions/EditDashboardSwitch.tsx | 9 +- 7 files changed, 93 insertions(+), 75 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a34461f87d6..e98c47ddfac 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1817,7 +1817,7 @@ }, "public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx": { "react-hooks/rules-of-hooks": { - "count": 4 + "count": 5 } }, "public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": { diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index 7251d2ed85c..6332b5ceefa 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -97,7 +97,13 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, root see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
+
{ - if (ref) { - dashboard.onSetScrollRef(new DivScrollElement(ref)); - } - }; - const sidebarContext = useSidebar({ hasOpenPane: Boolean(openPane), contentMargin: 1, @@ -88,39 +87,77 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls editPane.clearSelection(); }; + const onBodyRef = (ref: HTMLDivElement | null) => { + if (ref) { + dashboard.onSetScrollRef(new DivScrollElement(ref)); + } + }; + + function renderBody() { + // In kiosk mode the full document body scrolls so we don't need to wrap in our own scrollbar + if (isInKioskMode) { + return ( +
+ {body} +
+ ); + } + + return ( +
+
+ {body} +
+ + + + +
+ ); + } + return (
- - {hasUid && canStar && } - {hasUid && canStar && } - {renderDynamicNavActions()} - - } - /> -
+
{controls}
-
-
- {body} -
- - - -
+ {renderBody()}
); } +function useUpdateAppChromeActions(dashboard: DashboardScene) { + const { chrome } = useGrafana(); + + useLayoutEffect(() => { + const hasUid = Boolean(dashboard.state.uid); + const canStar = Boolean(dashboard.state.meta.canStar); + + const breadcrumbActions = ( + <> + {hasUid && canStar && } + {hasUid && canStar && } + {renderDynamicNavActions()} + + ); + + chrome.update({ breadcrumbActions }); + + return () => { + chrome.update({ breadcrumbActions: undefined }); + }; + }, [chrome, dashboard]); +} + function renderDynamicNavActions() { const dashboard = getDashboardSrv().getCurrent()!; const showProps = { dashboard }; @@ -152,13 +189,17 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) { bodyWrapper: css({ label: 'body-wrapper', display: 'flex', - flexDirection: 'row', + flexDirection: 'column', flexGrow: 1, position: 'relative', flex: '1 1 0', overflow: 'hidden', }), - bodyWithToolbar: css({ + bodyWrapperKiosk: css({ + padding: theme.spacing(0, 2, 2, 2), + overflow: 'unset', + }), + scrollContainer: css({ display: 'flex', flexDirection: 'column', flexGrow: 1, diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 4e3fa954a51..00655036d04 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -32,7 +32,7 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { VariablesChanged } from 'app/features/variables/types'; -import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types/dashboard'; +import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { ShowConfirmModalEvent } from 'app/types/events'; import { @@ -140,8 +140,6 @@ export interface DashboardSceneState extends SceneObjectState { editPanel?: PanelEditor; /** Scene object that handles the current drawer or modal */ overlay?: SceneObject; - /** Kiosk mode */ - kioskMode?: KioskMode; /** Share view */ shareView?: string; /** Renders panels in grid and filtered */ diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index c065b45d98f..52deaf2523a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -1,5 +1,4 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; -import { KioskMode } from 'app/types/dashboard'; import { DashboardScene } from './DashboardScene'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; @@ -22,25 +21,6 @@ describe('DashboardSceneUrlSync', () => { layout.state.grid.setState({ UNSAFE_fitPanels: true }); expect(scene.urlSync?.getUrlState().autofitpanels).toBe('true'); }); - - it('Should set kiosk mode when url has kiosk', () => { - const scene = buildTestScene(); - - scene.urlSync?.updateFromUrl({ kiosk: 'invalid' }); - expect(scene.state.kioskMode).toBe(undefined); - scene.urlSync?.updateFromUrl({ kiosk: '' }); - expect(scene.state.kioskMode).toBe(KioskMode.Full); - scene.urlSync?.updateFromUrl({ kiosk: 'true' }); - expect(scene.state.kioskMode).toBe(KioskMode.Full); - }); - - it('Should get the kiosk mode from the scene state', () => { - const scene = buildTestScene(); - - expect(scene.urlSync?.getUrlState().kiosk).toBe(undefined); - scene.setState({ kioskMode: KioskMode.Full }); - expect(scene.urlSync?.getUrlState().kiosk).toBe('true'); - }); }); describe('entering edit mode', () => { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 44c51dd4de7..45b35c3d57f 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -1,6 +1,5 @@ import { SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; -import { KioskMode } from 'app/types/dashboard'; import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; @@ -15,7 +14,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { constructor(private _scene: DashboardScene) {} getKeys(): string[] { - return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels', 'kiosk', 'shareView']; + return ['inspect', 'viewPanel', 'editPanel', 'editview', 'autofitpanels', 'shareView']; } getUrlState(): SceneObjectUrlValues { @@ -26,7 +25,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { viewPanel: state.viewPanel, editview: state.editview?.getUrlKey(), editPanel: state.editPanel?.getUrlKey() || undefined, - kiosk: state.kioskMode === KioskMode.Full ? 'true' : undefined, shareView: state.shareView, orgId: contextSrv.user.orgId.toString(), }; @@ -117,12 +115,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } } - if (typeof values.kiosk === 'string') { - if (values.kiosk === 'true' || values.kiosk === '') { - update.kioskMode = KioskMode.Full; - } - } - if (Object.keys(update).length > 0) { this._scene.setState(update); } diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx index 777c3892860..8c43b455a8d 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx @@ -1,6 +1,6 @@ import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; +import { ToolbarButton } from '@grafana/ui'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; import { trackDashboardSceneEditButtonClicked } from 'app/features/dashboard-scene/utils/tracking'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; @@ -17,10 +17,11 @@ export const EditDashboardSwitch = ({ dashboard }: ToolbarActionProps) => { } return ( - + ); }; From d1ef2837fc69158fc3d2fdc5b4dde869cf2394bd Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:04:19 +0000 Subject: [PATCH 14/21] I18n: Download translations from Crowdin (#115383) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 6 +++++- public/locales/de-DE/grafana.json | 6 +++++- public/locales/es-ES/grafana.json | 6 +++++- public/locales/fr-FR/grafana.json | 6 +++++- public/locales/hu-HU/grafana.json | 6 +++++- public/locales/id-ID/grafana.json | 6 +++++- public/locales/it-IT/grafana.json | 6 +++++- public/locales/ja-JP/grafana.json | 6 +++++- public/locales/ko-KR/grafana.json | 6 +++++- public/locales/nl-NL/grafana.json | 6 +++++- public/locales/pl-PL/grafana.json | 6 +++++- public/locales/pt-BR/grafana.json | 6 +++++- public/locales/pt-PT/grafana.json | 6 +++++- public/locales/ru-RU/grafana.json | 6 +++++- public/locales/sv-SE/grafana.json | 6 +++++- public/locales/tr-TR/grafana.json | 6 +++++- public/locales/zh-Hans/grafana.json | 6 +++++- public/locales/zh-Hant/grafana.json | 6 +++++- 18 files changed, 90 insertions(+), 18 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 316215abf0c..c64175c4d1f 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -5171,6 +5171,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfigurovat", "menu-use-library-panel": "Použít panel knihovny", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6187,7 +6188,10 @@ "no-data-found": "Nebyla nalezena žádná data" }, "inspect-json-tab": { - "apply": "Použít" + "apply": "Použít", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Dynamicky vypočítá interval vydělením časového rozsahu zadaným počtem", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 26c780b333a..0da32e80f94 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfigurieren", "menu-use-library-panel": "Bibliotheks-Panel verwenden", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Keine Daten gefunden" }, "inspect-json-tab": { - "apply": "Anwenden" + "apply": "Anwenden", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Berechnet dynamisch das Intervall, indem der Zeitbereich durch die angegebene Anzahl dividiert wird", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index d0e83225d56..c44e8c8ff3b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configurar", "menu-use-library-panel": "Usar panel de la librería", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "No se han encontrado datos" }, "inspect-json-tab": { - "apply": "Aplicar" + "apply": "Aplicar", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Calcula dinámicamente el intervalo dividiendo el intervalo de tiempo por el recuento especificado", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 1944e137bb7..aecae73acab 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configurer", "menu-use-library-panel": "Utiliser le panneau de bibliothèque", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Aucune donnée n’a été trouvée" }, "inspect-json-tab": { - "apply": "Appliquer" + "apply": "Appliquer", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Calcule dynamiquement l’intervalle en divisant la plage de temps par le nombre spécifié", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index b94b8cd7fe7..7f049004022 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfigurálás", "menu-use-library-panel": "Könyvtárpanel használata", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Nem található adat" }, "inspect-json-tab": { - "apply": "Alkalmaz" + "apply": "Alkalmaz", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Dinamikusan kiszámítja az intervallumot úgy, hogy az időtartományt elosztja a megadott számmal", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 5f1dda49d0a..1676500a4b3 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -5114,6 +5114,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfigurasikan", "menu-use-library-panel": "Gunakan panel pustaka", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6124,7 +6125,10 @@ "no-data-found": "Tidak ada data yang ditemukan" }, "inspect-json-tab": { - "apply": "Terapkan" + "apply": "Terapkan", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Menghitung interval secara dinamis dengan membagi rentang waktu dengan jumlah yang ditentukan", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index c8b728faa0f..ef4e977fca8 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configura", "menu-use-library-panel": "Usa il pannello della libreria", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Nessun dato trovato" }, "inspect-json-tab": { - "apply": "Applica" + "apply": "Applica", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Calcola dinamicamente l'intervallo dividendo l'intervallo di tempo per il conteggio specificato", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index d633cd80893..feb94f34766 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -5114,6 +5114,7 @@ "empty-state-message": "", "menu-open-panel-editor": "構成", "menu-use-library-panel": "ライブラリパネルを使用", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6124,7 +6125,10 @@ "no-data-found": "データが見つかりません" }, "inspect-json-tab": { - "apply": "適用" + "apply": "適用", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "指定した数で時間範囲を分割して、間隔を動的に計算します", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 64bab4a6953..24ea525fbb6 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -5114,6 +5114,7 @@ "empty-state-message": "", "menu-open-panel-editor": "구성", "menu-use-library-panel": "라이브러리 패널 사용", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6124,7 +6125,10 @@ "no-data-found": "데이터를 찾을 수 없음" }, "inspect-json-tab": { - "apply": "적용" + "apply": "적용", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "시간 범위를 지정된 수로 나누어 동적으로 간격을 계산합니다.", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index cd16286eea8..facfe8cee8e 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configureren", "menu-use-library-panel": "Bibliotheekpaneel gebruiken", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Geen info gevonden" }, "inspect-json-tab": { - "apply": "Toepassen" + "apply": "Toepassen", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Berekent het interval dynamisch door het tijdsbereik te delen door het opgegeven aantal", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 989a1d9be78..98cfa899c2e 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -5171,6 +5171,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfiguruj", "menu-use-library-panel": "Użyj panelu biblioteki", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6187,7 +6188,10 @@ "no-data-found": "Nie znaleziono danych" }, "inspect-json-tab": { - "apply": "Zastosuj" + "apply": "Zastosuj", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Dynamicznie oblicza odstęp czasu, dzieląc zakres przez określoną liczbę", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 5480b73ca27..542bd14c8a1 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configuração", "menu-use-library-panel": "Usar painel da biblioteca", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Nenhum dado encontrado" }, "inspect-json-tab": { - "apply": "Aplicar" + "apply": "Aplicar", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Calcula dinamicamente o intervalo ao dividir o intervalo de tempo pela contagem especificada", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 53cf4c8fb46..30c132e560d 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Configurar", "menu-use-library-panel": "Utilizar o painel de biblioteca", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Nenhum dado encontrado" }, "inspect-json-tab": { - "apply": "Aplicar" + "apply": "Aplicar", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Calcula dinamicamente o intervalo ao dividir o intervalo de tempo pela contagem especificada", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index a4236136a35..ade7a64fe21 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -5171,6 +5171,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Настроить", "menu-use-library-panel": "Использовать панель библиотеки", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6187,7 +6188,10 @@ "no-data-found": "Данные не найдены" }, "inspect-json-tab": { - "apply": "Применить" + "apply": "Применить", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Динамически рассчитывает интервал путем деления временного диапазона на указанное количество", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 13f96c68f3e..45669bf75c5 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Konfigurera", "menu-use-library-panel": "Använd bibliotekspanel", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Inga data hittades" }, "inspect-json-tab": { - "apply": "Tillämpa" + "apply": "Tillämpa", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Beräknar intervallet dynamiskt genom att dividera tidsintervallet med det angivna antalet", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 96b92518f07..2277af7feb5 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -5133,6 +5133,7 @@ "empty-state-message": "", "menu-open-panel-editor": "Yapılandır", "menu-use-library-panel": "Kütüphane panelini kullan", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6145,7 +6146,10 @@ "no-data-found": "Veri bulunamadı" }, "inspect-json-tab": { - "apply": "Uygula" + "apply": "Uygula", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "Zaman aralığını belirtilen sayıya bölerek aralığı dinamik olarak hesaplar", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 87666d55d39..c6a34de778b 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -5114,6 +5114,7 @@ "empty-state-message": "", "menu-open-panel-editor": "配置", "menu-use-library-panel": "使用库面板", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6124,7 +6125,10 @@ "no-data-found": "未找到数据" }, "inspect-json-tab": { - "apply": "应用" + "apply": "应用", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "通过将时间范围除以指定的计数来动态计算间隔", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index d752e8ef1c1..4a569b10b0f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -5114,6 +5114,7 @@ "empty-state-message": "", "menu-open-panel-editor": "設定", "menu-use-library-panel": "使用資料庫面板", + "missing-config": "", "suggestions": { "empty-state-message": "" } @@ -6124,7 +6125,10 @@ "no-data-found": "找不到數據" }, "inspect-json-tab": { - "apply": "套用" + "apply": "套用", + "error-invalid-json": "", + "error-invalid-v2-panel": "", + "validation-error": "" }, "interval-variable-form": { "description-auto-option": "透過將時間範圍除以指定的計數來動態計算間隔", From fea972cb11496a14534272baef9aa6bbc8c5f5a3 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 16 Dec 2025 14:11:46 +0300 Subject: [PATCH 15/21] Dashboards: Avoid infra/log in apps (#115396) --- .golangci.yml | 20 +++++++++---- apps/dashboard/go.mod | 2 +- .../pkg/migration/schemaversion/cache.go | 28 ++++++++----------- .../pkg/migration/schemaversion/cache_test.go | 24 ++++++++-------- .../schemaversion/datasource_utils.go | 6 ++-- 5 files changed, 42 insertions(+), 38 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 3b7871662e2..b52a986d435 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,7 @@ # Others can set up the YAML LSP manually, which supports schemas: https://github.com/redhat-developer/yaml-language-server # $schema: https://golangci-lint.run/jsonschema/golangci.jsonschema.json -version: "2" +version: '2' run: timeout: 15m concurrency: 10 @@ -83,6 +83,16 @@ linters: deny: - pkg: github.com/grafana/grafana/pkg desc: apps/playlist is not allowed to import grafana core + apps-dashboard: + list-mode: lax + files: + - ./apps/dashboard/* + - ./apps/dashboard/**/* + allow: + - github.com/grafana/grafana/pkg/apimachinery + deny: + - pkg: github.com/grafana/grafana/pkg + desc: apps/dashboard is not allowed to import grafana core apps-secret: list-mode: lax files: @@ -281,16 +291,16 @@ linters: text: G306 - linters: - gosec - text: "401" + text: '401' - linters: - gosec - text: "402" + text: '402' - linters: - gosec - text: "501" + text: '501' - linters: - gosec - text: "404" + text: '404' - linters: - errorlint text: non-wrapping format verb for fmt.Errorf diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 2182e0af949..00fe99f0c9c 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -9,6 +9,7 @@ require ( github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana-plugin-sdk-go v0.284.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 @@ -57,7 +58,6 @@ require ( github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.7.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/dashboard/pkg/migration/schemaversion/cache.go b/apps/dashboard/pkg/migration/schemaversion/cache.go index f31eaa14e1a..2548c2cc5ba 100644 --- a/apps/dashboard/pkg/migration/schemaversion/cache.go +++ b/apps/dashboard/pkg/migration/schemaversion/cache.go @@ -5,12 +5,11 @@ import ( "sync" "time" - "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/infra/log" "github.com/hashicorp/golang-lru/v2/expirable" - k8srequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/endpoints/request" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" ) const defaultCacheSize = 1000 @@ -32,17 +31,15 @@ type cachedProvider[T any] struct { fetch func(context.Context) T cache *expirable.LRU[string, T] // LRU cache: namespace to cache entry inFlight sync.Map // map[string]*sync.Mutex - per-namespace fetch locks - logger log.Logger } // newCachedProvider creates a new cachedProvider. // The fetch function should be able to handle context with different namespaces. // A non-positive size turns LRU mechanism off (cache of unlimited size). // A non-positive cacheTTL disables TTL expiration. -func newCachedProvider[T any](fetch func(context.Context) T, size int, cacheTTL time.Duration, logger log.Logger) *cachedProvider[T] { +func newCachedProvider[T any](fetch func(context.Context) T, size int, cacheTTL time.Duration) *cachedProvider[T] { cacheProvider := &cachedProvider[T]{ - fetch: fetch, - logger: logger, + fetch: fetch, } cacheProvider.cache = expirable.NewLRU(size, func(key string, value T) { cacheProvider.inFlight.Delete(key) @@ -53,14 +50,13 @@ func newCachedProvider[T any](fetch func(context.Context) T, size int, cacheTTL // Get returns the cached value if it's still valid, otherwise calls fetch and caches the result. func (p *cachedProvider[T]) Get(ctx context.Context) T { // Get namespace info from ctx - nsInfo, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { + namespace, ok := request.NamespaceFrom(ctx) + if !ok { // No namespace, fall back to direct fetch call without caching - p.logger.Warn("Unable to get namespace info from context, skipping cache", "error", err) + logging.FromContext(ctx).Warn("Unable to get namespace info from context, skipping cache") return p.fetch(ctx) } - namespace := nsInfo.Value // Fast path: check if cache is still valid if entry, ok := p.cache.Get(namespace); ok { return entry @@ -81,7 +77,7 @@ func (p *cachedProvider[T]) Get(ctx context.Context) T { } // Fetch outside the main lock - only this namespace is blocked - p.logger.Debug("cache miss or expired, fetching new value", "namespace", namespace) + logging.FromContext(ctx).Debug("cache miss or expired, fetching new value", "namespace", namespace) value := p.fetch(ctx) // Update the cache for this namespace @@ -93,12 +89,12 @@ func (p *cachedProvider[T]) Get(ctx context.Context) T { // Preload loads data into the cache for the given namespaces. func (p *cachedProvider[T]) Preload(ctx context.Context, nsInfos []types.NamespaceInfo) { // Build the cache using a context with the namespace - p.logger.Info("preloading cache", "nsInfos", len(nsInfos)) + logging.FromContext(ctx).Info("preloading cache", "nsInfos", len(nsInfos)) startedAt := time.Now() defer func() { - p.logger.Info("finished preloading cache", "nsInfos", len(nsInfos), "elapsed", time.Since(startedAt)) + logging.FromContext(ctx).Info("finished preloading cache", "nsInfos", len(nsInfos), "elapsed", time.Since(startedAt)) }() for _, nsInfo := range nsInfos { - p.cache.Add(nsInfo.Value, p.fetch(k8srequest.WithNamespace(ctx, nsInfo.Value))) + p.cache.Add(nsInfo.Value, p.fetch(request.WithNamespace(ctx, nsInfo.Value))) } } diff --git a/apps/dashboard/pkg/migration/schemaversion/cache_test.go b/apps/dashboard/pkg/migration/schemaversion/cache_test.go index f044ab0f813..081455143e4 100644 --- a/apps/dashboard/pkg/migration/schemaversion/cache_test.go +++ b/apps/dashboard/pkg/migration/schemaversion/cache_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - authlib "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/infra/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apiserver/pkg/endpoints/request" + + authlib "github.com/grafana/authlib/types" ) // testProvider tracks how many times get() is called @@ -44,7 +44,7 @@ func TestCachedProvider_CacheHit(t *testing.T) { underlying := newTestProvider(datasources) // Test newCachedProvider directly instead of the wrapper - cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute) // Use "default" namespace (org 1) - this is the standard Grafana namespace format ctx := request.WithNamespace(context.Background(), "default") @@ -69,7 +69,7 @@ func TestCachedProvider_NamespaceIsolation(t *testing.T) { } underlying := newTestProvider(datasources) - cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute) // Use "default" (org 1) and "org-2" (org 2) - standard Grafana namespace formats ctx1 := request.WithNamespace(context.Background(), "default") @@ -102,7 +102,7 @@ func TestCachedProvider_NoNamespaceFallback(t *testing.T) { } underlying := newTestProvider(datasources) - cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute) // Context without namespace - should fall back to direct provider call ctx := context.Background() @@ -123,7 +123,7 @@ func TestCachedProvider_ConcurrentAccess(t *testing.T) { } underlying := newTestProvider(datasources) - cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute) // Use "default" namespace (org 1) ctx := request.WithNamespace(context.Background(), "default") @@ -155,7 +155,7 @@ func TestCachedProvider_ConcurrentNamespaces(t *testing.T) { } underlying := newTestProvider(datasources) - cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, time.Minute) var wg sync.WaitGroup numOrgs := 10 @@ -198,7 +198,7 @@ func TestCachedProvider_CorrectDataPerNamespace(t *testing.T) { "org-2": {{UID: "org2-ds", Type: "loki", Name: "Org2 DS", Default: true}}, }, } - cached := newCachedProvider(underlying.Index, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.Index, defaultCacheSize, time.Minute) // Use valid namespace formats ctx1 := request.WithNamespace(context.Background(), "default") @@ -228,7 +228,7 @@ func TestCachedProvider_PreloadMultipleNamespaces(t *testing.T) { "org-3": {{UID: "org3-ds", Type: "tempo", Name: "Org3 DS", Default: true}}, }, } - cached := newCachedProvider(underlying.Index, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(underlying.Index, defaultCacheSize, time.Minute) // Preload multiple namespaces nsInfos := []authlib.NamespaceInfo{ @@ -346,7 +346,7 @@ func TestCachedProvider_TTLExpiration(t *testing.T) { underlying := newTestProvider(datasources) // Use a very short TTL for testing shortTTL := 50 * time.Millisecond - cached := newCachedProvider(underlying.get, defaultCacheSize, shortTTL, log.New("test")) + cached := newCachedProvider(underlying.get, defaultCacheSize, shortTTL) ctx := request.WithNamespace(context.Background(), "default") @@ -379,7 +379,7 @@ func TestCachedProvider_ParallelNamespacesFetch(t *testing.T) { {UID: "ds1", Type: "prometheus", Name: "Prometheus", Default: true}, }, } - cached := newCachedProvider(provider.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(provider.get, defaultCacheSize, time.Minute) numNamespaces := 5 var wg sync.WaitGroup @@ -421,7 +421,7 @@ func TestCachedProvider_SameNamespaceSerialFetch(t *testing.T) { {UID: "ds1", Type: "prometheus", Name: "Prometheus", Default: true}, }, } - cached := newCachedProvider(provider.get, defaultCacheSize, time.Minute, log.New("test")) + cached := newCachedProvider(provider.get, defaultCacheSize, time.Minute) numGoroutines := 10 var wg sync.WaitGroup diff --git a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go index 9deee29b414..c7215495dc9 100644 --- a/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go +++ b/apps/dashboard/pkg/migration/schemaversion/datasource_utils.go @@ -3,8 +3,6 @@ package schemaversion import ( "context" "time" - - "github.com/grafana/grafana/pkg/infra/log" ) // Shared utility functions for datasource migrations across different schema versions. @@ -36,7 +34,7 @@ func WrapIndexProviderWithCache(provider DataSourceIndexProvider, cacheTTL time. return provider } return &cachedIndexProvider{ - newCachedProvider[*DatasourceIndex](provider.Index, defaultCacheSize, cacheTTL, log.New("schemaversion.dsindexprovider")), + newCachedProvider[*DatasourceIndex](provider.Index, defaultCacheSize, cacheTTL), } } @@ -46,7 +44,7 @@ func WrapLibraryElementProviderWithCache(provider LibraryElementIndexProvider, c return provider } return &cachedLibraryElementProvider{ - newCachedProvider[[]LibraryElementInfo](provider.GetLibraryElementInfo, defaultCacheSize, cacheTTL, log.New("schemaversion.leindexprovider")), + newCachedProvider[[]LibraryElementInfo](provider.GetLibraryElementInfo, defaultCacheSize, cacheTTL), } } From 18837682cc68b33a217f9f8c332cadb2ad40acf8 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:01:03 +0100 Subject: [PATCH 16/21] Scopes: ScopesNavigation preload functionality (#115354) * Add devenv configs * Initial preload functionality * Remove support for expandOnLoad * Add tests * Remove unnecessary go code --- devenv/scopes/scopes-config.yaml | 2 + devenv/scopes/scopes.go | 16 +- .../ScopesDashboardsService.test.ts | 448 ++++++++++++++++++ .../dashboards/ScopesDashboardsService.ts | 33 +- .../app/features/scopes/dashboards/types.ts | 1 + 5 files changed, 492 insertions(+), 8 deletions(-) diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index d18679f6dcd..ef800415547 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -125,6 +125,7 @@ navigationTree: url: /d/_5rDmaQiz scope: shoe-org subScope: shoes + preLoadSubScopeChildren: true children: - name: shoes-overview title: Overview @@ -141,6 +142,7 @@ navigationTree: url: /d/edediimbjhdz4b scope: shoes subScope: frontend + preLoadSubScopeChildren: true children: - name: frontend-api title: API Metrics diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index b252072c398..e3f4de80d21 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -83,6 +83,7 @@ type NavigationConfig struct { Title string `yaml:"title"` // Display title Groups []string `yaml:"groups"` // Optional groups for categorization DisableSubScopeSelection bool `yaml:"disableSubScopeSelection"` // Makes the subscope not selectable + PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren"` // Preload children of subScope without updating UI } // NavigationTreeNode represents a node in the navigation tree structure @@ -94,6 +95,7 @@ type NavigationTreeNode struct { SubScope string `yaml:"subScope,omitempty"` Groups []string `yaml:"groups,omitempty"` DisableSubScopeSelection bool `yaml:"disableSubScopeSelection,omitempty"` + PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren,omitempty"` // Preload children of subScope without updating UI Children []NavigationTreeNode `yaml:"children,omitempty"` } @@ -318,6 +320,7 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error URL: nav.URL, Scope: prefixedScope, DisableSubScopeSelection: nav.DisableSubScopeSelection, + PreLoadSubScopeChildren: nav.PreLoadSubScopeChildren, } if nav.SubScope != "" { @@ -353,14 +356,14 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error return err } + // Get the created resource to retrieve its resourceVersion for status update + createdNav, err := c.getScopeNavigation(prefixedName) + if err != nil { + return fmt.Errorf("failed to get created navigation: %w", err) + } + // Update status in a second request (status is a subresource) if nav.Title != "" || len(nav.Groups) > 0 { - // Get the created resource to retrieve its resourceVersion and existing spec - createdNav, err := c.getScopeNavigation(prefixedName) - if err != nil { - return fmt.Errorf("failed to get created navigation: %w", err) - } - statusResource := v0alpha1.ScopeNavigation{ TypeMeta: metav1.TypeMeta{ APIVersion: apiVersion, @@ -411,6 +414,7 @@ func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCo Scope: node.Scope, Title: node.Title, DisableSubScopeSelection: node.DisableSubScopeSelection, + PreLoadSubScopeChildren: node.PreLoadSubScopeChildren, } if node.SubScope != "" { nav.SubScope = node.SubScope diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 082c8c790cc..2c5e4b28fc6 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -888,6 +888,454 @@ describe('ScopesDashboardsService', () => { }); }); + describe('preLoadSubScopeChildren', () => { + beforeEach(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + }); + + afterEach(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('should set preLoadSubScopeChildren on folder when navigation has it set to true', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.preLoadSubScopeChildren).toBe(true); + expect(folder.subScopeName).toBe('subScope1'); + } + }); + + it('should set preLoadSubScopeChildren to false when navigation has it set to false', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + preLoadSubScopeChildren: false, + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.preLoadSubScopeChildren).toBe(false); + } + }); + + it('should set preLoadSubScopeChildren to undefined when navigation does not have it', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.preLoadSubScopeChildren).toBeUndefined(); + } + }); + + it('should automatically fetch subScope items for folders with preLoadSubScopeChildren set to true', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'mimir', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Mimir Dashboards', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + // Mock items returned when fetching 'mimir' subScope + const mimirItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('scope1')) { + return Promise.resolve(mockNavigations); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(mimirItems); + } + return Promise.resolve([]); + }); + + await service.fetchDashboards(['scope1']); + + // Wait for the preload to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify that fetchScopeNavigations was called for the subScope + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']); + + // Verify the folder now has content from the preloaded items + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('mimir')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + // The preloaded items should be in the folder + expect(folder.folders['General']).toBeDefined(); + expect(folder.folders['General'].suggestedNavigations['/d/mimir-dashboard-1']).toBeDefined(); + } + }); + + it('should not fetch subScope items for folders without preLoadSubScopeChildren', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'mimir', + // preLoadSubScopeChildren is not set + }, + status: { + title: 'Mimir Dashboards', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Wait to ensure no additional fetch happens + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify that fetchScopeNavigations was only called once (for the initial fetch) + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledTimes(1); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']); + }); + + it('should recursively preload nested folders with preLoadSubScopeChildren', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'level1', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Level 1 Folder', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + // Level 1 items include a folder with preLoadSubScopeChildren + const level1Items: ScopeNavigation[] = [ + { + metadata: { name: 'level2-nav' }, + spec: { + scope: 'level1', + subScope: 'level2', + url: '/d/level2-dashboard', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Level 2 Folder', + }, + }, + ]; + + // Level 2 items + const level2Items: ScopeNavigation[] = [ + { + metadata: { name: 'level2-item-1' }, + spec: { + scope: 'level2', + url: '/d/level2-dashboard-1', + }, + status: { + title: 'Level 2 Dashboard 1', + groups: ['Deep'], + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('scope1')) { + return Promise.resolve(mockNavigations); + } + if (scopeNames.includes('level1')) { + return Promise.resolve(level1Items); + } + if (scopeNames.includes('level2')) { + return Promise.resolve(level2Items); + } + return Promise.resolve([]); + }); + + await service.fetchDashboards(['scope1']); + + // Wait for all preloads to complete (need to wait for both levels) + await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify all levels were fetched + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level1']); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level2']); + }); + + it('should handle multiple folders with preLoadSubScopeChildren', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'mimir', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Mimir Dashboards', + }, + metadata: { + name: 'nav1', + }, + }, + { + spec: { + url: '/d/dashboard2', + scope: 'scope1', + subScope: 'loki', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Loki Dashboards', + }, + metadata: { + name: 'nav2', + }, + }, + ]; + + const mimirItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' }, + status: { title: 'Mimir Dashboard 1', groups: ['General'] }, + }, + ]; + + const lokiItems: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { scope: 'loki', url: '/d/loki-dashboard-1' }, + status: { title: 'Loki Dashboard 1', groups: ['General'] }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('scope1')) { + return Promise.resolve(mockNavigations); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(mimirItems); + } + if (scopeNames.includes('loki')) { + return Promise.resolve(lokiItems); + } + return Promise.resolve([]); + }); + + await service.fetchDashboards(['scope1']); + + // Wait for preloads to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify both subScopes were fetched + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['loki']); + }); + + it('should handle preload errors gracefully', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'failing-scope', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Failing Folder', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('scope1')) { + return Promise.resolve(mockNavigations); + } + if (scopeNames.includes('failing-scope')) { + return Promise.reject(new Error('Network error')); + } + return Promise.resolve([]); + }); + + // Should not throw + await service.fetchDashboards(['scope1']); + + // Wait for preload to attempt + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify the folder was still created (even though preload failed) + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('failing-scope')); + expect(folderKey).toBeDefined(); + }); + + it('should preload children after fetching subScope items when parent folder has items with preLoadSubScopeChildren', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'parent', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Parent Folder', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + // Parent items include a child with preLoadSubScopeChildren + const parentItems: ScopeNavigation[] = [ + { + metadata: { name: 'child-nav' }, + spec: { + scope: 'parent', + subScope: 'child', + url: '/d/child-dashboard', + preLoadSubScopeChildren: true, + }, + status: { + title: 'Child Folder', + }, + }, + ]; + + const childItems: ScopeNavigation[] = [ + { + metadata: { name: 'child-item-1' }, + spec: { + scope: 'child', + url: '/d/child-dashboard-1', + }, + status: { + title: 'Child Dashboard 1', + groups: ['Nested'], + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('scope1')) { + return Promise.resolve(mockNavigations); + } + if (scopeNames.includes('parent')) { + return Promise.resolve(parentItems); + } + if (scopeNames.includes('child')) { + return Promise.resolve(childItems); + } + return Promise.resolve([]); + }); + + await service.fetchDashboards(['scope1']); + + // Wait for cascading preloads + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Verify the chain of preloads occurred + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['parent']); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['child']); + }); + }); + describe('disableSubScopeSelection', () => { it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => { const mockNavigations: ScopeNavigation[] = [ diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index 4d6c686280d..a0def1b4fd5 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -253,9 +253,14 @@ export class ScopesDashboardsService extends ScopesServiceBase 0, }); + + // Preload children for folders with preLoadSubScopeChildren set + this.preloadSubScopeChildren(folders[''].folders, ['']); + } + }; + + /** + * Preloads children for folders that have preLoadSubScopeChildren set to true. + * This fetches the subScope items immediately when the navigation is first loaded, + * or when a parent subScope folder is fetched. + * @param foldersToCheck - The folders to check for preLoadSubScopeChildren + * @param basePath - The path to prepend when building the full path for each folder + */ + private preloadSubScopeChildren = (foldersToCheck: SuggestedNavigationsFoldersMap, basePath: string[]) => { + for (const [folderKey, folder] of Object.entries(foldersToCheck)) { + if (folder.preLoadSubScopeChildren && folder.subScopeName) { + const path = [...basePath, folderKey]; + this.fetchSubScopeItems(path, folder.subScopeName); + } } }; @@ -391,6 +415,10 @@ export class ScopesDashboardsService extends ScopesServiceBase; From 195bf681d14c379cd113a87b71456e7df9581e2f Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Tue, 16 Dec 2025 13:34:08 +0100 Subject: [PATCH 17/21] V2: Ensure refIds for queries (#115404) Ensure refIds for queries --- .../layoutSerializers/utils.test.ts | 75 ++++++++++++++++++- .../serialization/layoutSerializers/utils.ts | 21 +++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts index 328dc680e6e..ad644f92294 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -1,6 +1,6 @@ import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { getRuntimePanelDataSource } from './utils'; +import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils'; describe('getRuntimePanelDataSource', () => { it('should return uid and type when explicit datasource UID is provided', () => { @@ -140,3 +140,76 @@ describe('getRuntimePanelDataSource', () => { expect(result).toBeUndefined(); }); }); + +describe('ensureUniqueRefIds', () => { + const createQuery = (refId: string): PanelQueryKind => ({ + kind: 'PanelQuery', + spec: { + refId, + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'prometheus', + spec: {}, + }, + }, + }); + + it('should assign unique refIds to queries without refIds', () => { + const queries: PanelQueryKind[] = [createQuery(''), createQuery(''), createQuery('')]; + + const result = ensureUniqueRefIds(queries); + + expect(result[0].spec.refId).toBe('A'); + expect(result[1].spec.refId).toBe('B'); + expect(result[2].spec.refId).toBe('C'); + }); + + it('should preserve existing refIds and fill gaps', () => { + const queries: PanelQueryKind[] = [createQuery('A'), createQuery(''), createQuery('D'), createQuery('')]; + + const result = ensureUniqueRefIds(queries); + + expect(result[0].spec.refId).toBe('A'); + expect(result[1].spec.refId).toBe('B'); + expect(result[2].spec.refId).toBe('D'); + expect(result[3].spec.refId).toBe('C'); + }); + + it('should handle all queries having existing refIds', () => { + const queries: PanelQueryKind[] = [createQuery('A'), createQuery('B'), createQuery('C')]; + + const result = ensureUniqueRefIds(queries); + + expect(result[0].spec.refId).toBe('A'); + expect(result[1].spec.refId).toBe('B'); + expect(result[2].spec.refId).toBe('C'); + }); + + it('should only modify queries without refIds', () => { + const queries: PanelQueryKind[] = [createQuery('A'), createQuery(''), createQuery('C')]; + + const result = ensureUniqueRefIds(queries); + + // Existing refIds should be preserved + expect(result[0].spec.refId).toBe('A'); + expect(result[2].spec.refId).toBe('C'); + // Missing refId should be assigned + expect(result[1].spec.refId).toBe('B'); + }); + + it('should handle empty array', () => { + const result = ensureUniqueRefIds([]); + + expect(result).toEqual([]); + }); + + it('should handle single query without refId', () => { + const queries: PanelQueryKind[] = [createQuery('')]; + + const result = ensureUniqueRefIds(queries); + + expect(result[0].spec.refId).toBe('A'); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index fc634339607..6595268025d 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -1,3 +1,4 @@ +import { getNextRefId } from '@grafana/data'; import { config } from '@grafana/runtime'; import { SceneDataProvider, @@ -164,12 +165,15 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider return undefined; } + // Ensure all queries have unique refIds before converting to scene queries + const queriesWithUniqueRefIds = ensureUniqueRefIds(targets); + let dataProvider: SceneDataProvider | undefined = undefined; const datasource = getPanelDataSource(panelKind); dataProvider = new SceneQueryRunner({ datasource, - queries: targets.map(panelQueryKindToSceneQuery), + queries: queriesWithUniqueRefIds.map(panelQueryKindToSceneQuery), maxDataPoints: panel.data.spec.queryOptions.maxDataPoints ?? undefined, maxDataPointsFromWidth: true, cacheTimeout: panel.data.spec.queryOptions.cacheTimeout, @@ -341,6 +345,21 @@ export function getDataSourceForQuery(querySpecDS: DataSourceRef | undefined | n }; } +export function ensureUniqueRefIds(queries: PanelQueryKind[]): PanelQueryKind[] { + // Adapter to make PanelQueryKind[] work with getNextRefId (which expects { refId }[]) + const refIdAdapter = queries.map((q) => ({ refId: q.spec.refId })); + + for (let i = 0; i < queries.length; i++) { + if (!queries[i].spec.refId) { + const newRefId = getNextRefId(refIdAdapter); + queries[i] = { ...queries[i], spec: { ...queries[i].spec, refId: newRefId } }; + refIdAdapter[i] = { refId: newRefId }; + } + } + + return queries; +} + function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { // Add datasource to match Go backend V2→V1 conversion: // - If explicit UID (datasource.name) exists → add { uid, type } From c7c1dd4ead5e0c466788b4dbe75e927e66eaefd5 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Tue, 16 Dec 2025 13:47:26 +0100 Subject: [PATCH 18/21] V2 Schema: Fix panel Y position when converting from tabs to legacy rows in V2 -> v1 conversion. (#115373) * fix row y conversion * fix only for tabs * add tests examples --- .../v2beta1.tab-with-multiple-panels.json | 715 ++++++++++++++++++ ...ta1.tab-with-multiple-panels.v0alpha1.json | 511 +++++++++++++ ...eta1.tab-with-multiple-panels.v1beta1.json | 511 +++++++++++++ ...ta1.tab-with-multiple-panels.v2alpha1.json | 683 +++++++++++++++++ .../conversion/v2alpha1_to_v1beta1.go | 11 +- 5 files changed, 2428 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.tab-with-multiple-panels.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v2alpha1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.tab-with-multiple-panels.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.tab-with-multiple-panels.json new file mode 100644 index 00000000000..c2bb49cc5cb --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.tab-with-multiple-panels.json @@ -0,0 +1,715 @@ +{ +"kind": "DashboardWithAccessInfo", +"apiVersion": "dashboard.grafana.app/v2beta1", +"metadata": { + "name": "adt885j", + "namespace": "default", + "uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-12-16T10:44:31Z", + "labels": { + "grafana.app/deprecatedInternalID": "2409" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000001", + "grafana.app/updatedBy": "user:u000000001", + "grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z" + } +}, +"spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true + } + } + ], + "cursorSync": "Off", + "description": "", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel1", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana-testdata-datasource", + "version": "v0", + "datasource": { + "name": "PD8C576611E62080A" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel2", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "Panel3", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "Panel4", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Panel5", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "TabsLayout", + "spec": { + "tabs": [ + { + "kind": "TabsLayoutTab", + "spec": { + "title": "Tab1", + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 7, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 7, + "y": 0, + "width": 8, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 15, + "y": 0, + "width": 9, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + } + ] + } + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Dashboard with tabs", + "variables": [] +}, +"status": {}, +"access": { + "slug": "dashboard-with-tabs", + "url": "/d/adt885j/dashboard-with-tabs", + "isPublic": false, + "canSave": true, + "canEdit": true, + "canAdmin": true, + "canStar": true, + "canDelete": true, + "annotationsPermissions": { + "dashboard": { + "canAdd": true, + "canEdit": true, + "canDelete": true + }, + "organization": { + "canAdd": true, + "canEdit": true, + "canDelete": true + } + } +} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json new file mode 100644 index 00000000000..0357c22536a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json @@ -0,0 +1,511 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "adt885j", + "namespace": "default", + "uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-12-16T10:44:31Z", + "labels": { + "grafana.app/deprecatedInternalID": "2409" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000001", + "grafana.app/updatedBy": "user:u000000001", + "grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": -1, + "panels": [], + "title": "Tab1", + "type": "row" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 7, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Panel1", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 7, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel2", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel3", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel4", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel5", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Dashboard with tabs" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json new file mode 100644 index 00000000000..7a9ea77ba65 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json @@ -0,0 +1,511 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "adt885j", + "namespace": "default", + "uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-12-16T10:44:31Z", + "labels": { + "grafana.app/deprecatedInternalID": "2409" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000001", + "grafana.app/updatedBy": "user:u000000001", + "grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": -1, + "panels": [], + "title": "Tab1", + "type": "row" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 7, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Panel1", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 7, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel2", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel3", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel4", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "refId": "A" + } + ], + "title": "Panel5", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Dashboard with tabs" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v2alpha1.json new file mode 100644 index 00000000000..b7739508c2d --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v2alpha1.json @@ -0,0 +1,683 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "adt885j", + "namespace": "default", + "uid": "yTWet6JgBjlRIWnqRE9ZOmUycfT0tEkr2mljaln1GWIX", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-12-16T10:44:31Z", + "labels": { + "grafana.app/deprecatedInternalID": "2409" + }, + "annotations": { + "grafana.app/createdBy": "user:u000000001", + "grafana.app/updatedBy": "user:u000000001", + "grafana.app/updatedTimestamp": "2025-12-16T10:51:14Z" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true + } + } + ], + "cursorSync": "Off", + "description": "", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel1", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "grafana-testdata-datasource", + "spec": {} + }, + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel2", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "Panel3", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "Panel4", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Panel5", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "TabsLayout", + "spec": { + "tabs": [ + { + "kind": "TabsLayoutTab", + "spec": { + "title": "Tab1", + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 7, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 7, + "y": 0, + "width": 8, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 15, + "y": 0, + "width": 9, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + } + ] + } + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Dashboard with tabs", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 73fd97b2089..438aad5ead7 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -495,6 +495,9 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash currentY = getMaxYFromPanels(nestedPanels, currentY) } else if tab.Spec.Layout.GridLayoutKind != nil { // GridLayout inside tab + baseY := currentY + maxY := currentY + for _, item := range tab.Spec.Layout.GridLayoutKind.Spec.Items { element, ok := elements[item.Spec.Element.Name] if !ok { @@ -502,7 +505,7 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash } adjustedItem := item - adjustedItem.Spec.Y = item.Spec.Y + currentY + adjustedItem.Spec.Y = item.Spec.Y + baseY panel, err := convertPanelFromElement(&element, &adjustedItem) if err != nil { @@ -511,10 +514,12 @@ func processTabItem(elements map[string]dashv2alpha1.DashboardElement, tab *dash panels = append(panels, panel) panelEndY := adjustedItem.Spec.Y + item.Spec.Height - if panelEndY > currentY { - currentY = panelEndY + if panelEndY > maxY { + maxY = panelEndY } } + + currentY = maxY } else if tab.Spec.Layout.AutoGridLayoutKind != nil { // AutoGridLayout inside tab - convert with Y offset autoGridPanels, err := convertAutoGridLayoutToPanelsWithOffset(elements, tab.Spec.Layout.AutoGridLayoutKind, currentY) From c0295d06a3ddfdc54567ab027e57e672d4f1e84e Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 16 Dec 2025 14:13:50 +0100 Subject: [PATCH 19/21] Alerting: Add rule_matcher filter to Prometheus rules API (#115297) **What is this feature?** Add `rule_matcher` filter to the Prometheus-compatible list rules API: `/api/prometheus/grafana/api/v1/rules`. It allows to filter rules by static labels (not by alert instance labels). **Special notes:** - Equality (`=`) and inequality (`!=`) matchers are pushed down to the database. Regex matchers (`=~`, `!~`) are applied in-memory at the API layer. - SQLite: Uses GLOB pattern matching - MySQL / PostgreSQL: Use JSON functions to compare label values --------- Co-authored-by: Konrad Lalik --- .../ngalert/api/api_prometheus_test.go | 183 +++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 143 ++++++++++---- .../ngalert/api/tooling/definitions/prom.go | 6 + pkg/services/ngalert/api/tooling/post.json | 9 + pkg/services/ngalert/api/tooling/spec.json | 9 + pkg/services/ngalert/models/alert_rule.go | 5 + pkg/services/ngalert/store/alert_rule.go | 39 ++++ .../ngalert/store/alert_rule_labels.go | 51 +++++ .../ngalert/store/alert_rule_labels_test.go | 136 +++++++++++++ pkg/services/ngalert/store/alert_rule_test.go | 152 ++++++++++++++ pkg/services/ngalert/store/json.go | 101 ++++++++++ pkg/services/ngalert/store/json_test.go | 185 ++++++++++++++++++ pkg/services/ngalert/tests/fakes/rules.go | 15 ++ pkg/tests/api/alerting/api_prometheus_test.go | 69 ++++++- .../alerting/unified/api/prometheusApi.ts | 3 + .../rule-list/hooks/grafanaFilter.test.ts | 67 ++++--- .../unified/rule-list/hooks/grafanaFilter.ts | 30 ++- .../rule-list/paginationLimits.test.ts | 5 +- 18 files changed, 1137 insertions(+), 71 deletions(-) create mode 100644 pkg/services/ngalert/store/alert_rule_labels.go create mode 100644 pkg/services/ngalert/store/alert_rule_labels_test.go create mode 100644 pkg/services/ngalert/store/json.go create mode 100644 pkg/services/ngalert/store/json_test.go diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 25acf56f7d3..71e25d4c963 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2886,6 +2886,189 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) } }) + + t.Run("with rule_matcher filter", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "alerting", "severity": "critical"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "Alerting", "severity": "warning"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "platform", "severity": "critical"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule4"), gen.WithLabels(map[string]string{"env": "production"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_special"), gen.WithLabels(map[string]string{"key": `value"with"quotes`}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_empty"), gen.WithLabels(map[string]string{"empty": ""}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_nonempty"), gen.WithLabels(map[string]string{"empty": "nonempty"}), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule_multiline"), gen.WithLabels(map[string]string{"description": "line1\nline2\\end\"quote"}), gen.WithNoNotificationSettings()) + + testCases := []struct { + name string + matchers []string + expectedUIDs []string + }{ + { + name: "equality matcher filters by team=alerting", + matchers: []string{`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule1"}, + }, + { + name: "inequality matcher filters severity!=warning", + matchers: []string{`{"name":"severity","value":"warning","isRegex":false,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "regex matcher filters team=~plat.*", + matchers: []string{`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`}, + expectedUIDs: []string{"rule3"}, + }, + { + name: "not-regex matcher filters severity!~warn.*", + matchers: []string{`{"name":"severity","value":"warn.*","isRegex":true,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "multiple matchers are ANDed", + matchers: []string{ + `{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`, + `{"name":"severity","value":"critical","isRegex":false,"isEqual":true}`, + }, + expectedUIDs: []string{"rule1"}, + }, + { + name: "matcher with non-existent label returns no rules", + matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{}, + }, + { + name: "equality matcher is case-sensitive", + matchers: []string{`{"name":"team","value":"Alerting","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule2"}, + }, + { + name: "quotes in label value are handled correctly", + matchers: []string{`{"name":"key","value":"value\"with\"quotes","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule_special"}, + }, + { + name: "no matchers returns all rules", + matchers: []string{}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + { + name: "empty string value matches correctly", + matchers: []string{`{"name":"empty","value":"","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_multiline"}, + }, + { + name: "special characters in label value are handled correctly", + matchers: []string{`{"name":"description","value":"line1\nline2\\end\"quote","isRegex":false,"isEqual":true}`}, + expectedUIDs: []string{"rule_multiline"}, + }, + { + name: "inequality matcher on non-existent label matches all rules", + matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":false}`}, + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + reqURL := "/api/v1/rules" + for i, matcher := range tc.matchers { + if i == 0 { + reqURL += "?rule_matcher=" + url.QueryEscape(matcher) + } else { + reqURL += "&rule_matcher=" + url.QueryEscape(matcher) + } + } + + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions}, + } + + resp := api.RouteGetRuleStatuses(ctx) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "success", res.Status) + + actualUIDs := []string{} + for _, group := range res.Data.RuleGroups { + for _, rule := range group.Rules { + actualUIDs = append(actualUIDs, rule.UID) + } + } + + require.ElementsMatch(t, tc.expectedUIDs, actualUIDs) + }) + } + }) + + t.Run("pagination with rule_matcher in-memory filtering", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create 3 groups with 2 rules each: + // Group 1 & 2: team=backend (won't match filter) + // Group 3: team=frontend (will match filter) + // This tests that pagination continues fetching when early pages are filtered out + + group1Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace1", RuleGroup: "group1"} + group2Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace2", RuleGroup: "group2"} + group3Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace3", RuleGroup: "group3"} + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings()) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule4"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings()) + + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule5"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithUID("rule6"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings()) + + // Request with regex rule_matcher filter for team=~"alerting" and group_limit=1 to force pagination + matcher := `{"name":"team","value":"alerting","isRegex":true,"isEqual":true}` + reqURL := "/api/v1/rules?rule_matcher=" + url.QueryEscape(matcher) + "&group_limit=1" + + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions}, + } + + resp := api.RouteGetRuleStatuses(ctx) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "success", res.Status) + + actualUIDs := []string{} + for _, group := range res.Data.RuleGroups { + for _, rule := range group.Rules { + actualUIDs = append(actualUIDs, rule.UID) + } + } + + // Should return group3 rules (rule5, rule6), pagination should continue past filtered groups + require.ElementsMatch(t, []string{"rule5", "rule6"}, actualUIDs) + }) } func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index ddbd1d2af4c..934805d74f4 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -33,6 +33,12 @@ import ( "go.opentelemetry.io/otel/trace" ) +const ( + queryIncludeInternalLabels = "includeInternalLabels" + queryRuleMatcher = "rule_matcher" + queryInstanceMatcher = "matcher" +) + type RuleStoreReader interface { GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) ListAlertRulesStoreV2 @@ -62,6 +68,20 @@ type PrometheusSrv struct { // Package-level OpenTelemetry tracer per Grafana instrumentation conventions. var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus") +// badRequestError returns a Prometheus-compatible error response for bad request data. +func badRequestError(err error) apimodels.RuleResponse { + return apimodels.RuleResponse{ + DiscoveryBase: apimodels.DiscoveryBase{ + Status: "error", + Error: err.Error(), + ErrorType: apiv1.ErrBadData, + }, + Data: apimodels.RuleDiscovery{ + RuleGroups: []apimodels.RuleGroup{}, + }, + } +} + func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv { return &PrometheusSrv{ log, @@ -73,8 +93,6 @@ func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status } } -const queryIncludeInternalLabels = "includeInternalLabels" - func getBoolWithDefault(vals url.Values, field string, d bool) bool { f := vals.Get(field) if f == "" { @@ -188,15 +206,15 @@ func getPanelIDFromQuery(v url.Values) (int64, error) { return 0, nil } -func getMatchersFromQuery(v url.Values) (labels.Matchers, error) { +func getMatchersFromQuery(v url.Values, paramName string) (labels.Matchers, error) { var matchers labels.Matchers - for _, s := range v["matcher"] { + for _, s := range v[paramName] { var m labels.Matcher if err := json.Unmarshal([]byte(s), &m); err != nil { return nil, err } if len(m.Name) == 0 { - return nil, errors.New("bad matcher: the name cannot be blank") + return nil, fmt.Errorf("bad %s: the name cannot be blank", paramName) } matchers = append(matchers, &m) } @@ -454,6 +472,7 @@ type paginationContext struct { stateFilterSet map[eval.State]struct{} healthFilterSet map[string]struct{} matchers labels.Matchers + ruleLabelMatchers labels.Matchers labelOptions []ngmodels.LabelOption limitAlertsPerRule int64 limitRulesPerGroup int64 @@ -476,6 +495,9 @@ func accumulateTotals(dest, source map[string]int64) { // fetchAndFilterPage fetches one page from the store and applies filters func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) { + // Split matchers: only equality/inequality are supported by the store + storeMatchers := filterOutRegexMatchers(ctx.ruleLabelMatchers) + byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ OrgID: ctx.opts.OrgID, @@ -488,6 +510,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert DataSourceUIDs: ctx.dataSourceUIDs, SearchTitle: ctx.title, SearchRuleGroup: ctx.searchRuleGroup, + LabelMatchers: storeMatchers, }, RuleType: ctx.ruleType, Limit: remainingGroups, @@ -534,6 +557,8 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert filterRulesByHealth(ruleGroup, ctx.healthFilterSet) } + filterRulesByLabelMatchers(ruleGroup, ctx.ruleLabelMatchers) + if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup { ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup] } @@ -546,6 +571,17 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert return result, nil } +func filterOutRegexMatchers(matchers labels.Matchers) labels.Matchers { + var result labels.Matchers + for _, m := range matchers { + if m.Type == labels.MatchEqual || m.Type == labels.MatchNotEqual { + result = append(result, m) + } + } + + return result +} + // paginateRuleGroups fetches pages until limits are satisfied applying filters at each step func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) { allGroups := []apimodels.RuleGroup{} @@ -644,21 +680,30 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt attribute.Int64("limit_rules", limitRulesPerGroup), attribute.Int64("limit_alerts", limitAlertsPerRule), ) - matchers, err := getMatchersFromQuery(opts.Query) + matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes(attribute.Int("matcher_count", len(matchers))) + ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher) + if err != nil { + return badRequestError(err) + } + regexCount := 0 + for _, m := range ruleLabelMatchers { + if m.Type == labels.MatchRegexp || m.Type == labels.MatchNotRegexp { + regexCount++ + } + } + span.SetAttributes( + attribute.Int("rule_matcher_count", len(ruleLabelMatchers)), + attribute.Int("rule_matcher_regex_count", regexCount), + ) + stateFilterSet, err := GetStatesFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes( attribute.Int("state_filter_count", len(stateFilterSet)), @@ -667,10 +712,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt healthFilterSet, err := GetHealthFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } span.SetAttributes( attribute.Int("health_filter_count", len(healthFilterSet)), @@ -808,6 +850,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt stateFilterSet: stateFilterSet, healthFilterSet: healthFilterSet, matchers: matchers, + ruleLabelMatchers: ruleLabelMatchers, labelOptions: labelOptions, limitAlertsPerRule: limitAlertsPerRule, limitRulesPerGroup: limitRulesPerGroup, @@ -833,6 +876,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt return ruleResponse } +// nolint:gocyclo func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ @@ -846,41 +890,30 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru dashboardUID := opts.Query.Get("dashboard_uid") panelID, err := getPanelIDFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(fmt.Errorf("invalid panel_id: %w", err)) } if dashboardUID == "" && panelID != 0 { - ruleResponse.Status = "error" - ruleResponse.Error = "panel_id must be set with dashboard_uid" - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(errors.New("panel_id must be set with dashboard_uid")) } limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1) limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1) - matchers, err := getMatchersFromQuery(opts.Query) + matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) + } + ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher) + if err != nil { + return badRequestError(err) } stateFilterSet, err := GetStatesFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } healthFilterSet, err := GetHealthFromQuery(opts.Query) if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = err.Error() - ruleResponse.ErrorType = apiv1.ErrBadData - return ruleResponse + return badRequestError(err) } var labelOptions []ngmodels.LabelOption @@ -913,6 +946,9 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru dataSourceUIDs := opts.Query["datasource_uid"] searchRuleGroup := opts.Query.Get("search.rule_group") + // Split matchers: only equality/inequality are supported by the store + storeMatchers := filterOutRegexMatchers(ruleLabelMatchers) + alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, NamespaceUIDs: namespaceUIDs, @@ -924,6 +960,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru SearchTitle: title, SearchRuleGroup: searchRuleGroup, DataSourceUIDs: dataSourceUIDs, + LabelMatchers: storeMatchers, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { @@ -978,6 +1015,10 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru filterRulesByHealth(ruleGroup, healthFilterSet) } + if len(ruleLabelMatchers) > 0 { + filterRulesByLabelMatchers(ruleGroup, ruleLabelMatchers) + } + if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup { ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup] } @@ -1105,6 +1146,30 @@ func filterRulesByHealth(ruleGroup *apimodels.RuleGroup, withHealthFast map[stri ruleGroup.Rules = filteredRules } +func filterRulesByLabelMatchers(ruleGroup *apimodels.RuleGroup, matchers labels.Matchers) { + if len(matchers) == 0 { + return + } + + filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules)) + + for _, rule := range ruleGroup.Rules { + ruleLabels := rule.Labels.Map() + matches := true + for _, m := range matchers { + if !m.Matches(ruleLabels[m.Name]) { + matches = false + break + } + } + if matches { + filteredRules = append(filteredRules, rule) + } + } + + ruleGroup.Rules = filteredRules +} + // This is the same as matchers.Matches but avoids the need to create a LabelSet func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool { for _, m := range matchers { diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 128c69787c0..05fb62dc283 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -462,4 +462,10 @@ type GetGrafanaRuleStatusesParams struct { // in: query // required: false Matchers []string `json:"matcher"` + + // Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {"type":0,"name":"severity","value":"critical"}). + // For equality matchers with empty string values (e.g., name=""), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior). + // in: query + // required: false + RuleLabelMatchers []string `json:"rule_matcher"` } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index d9d38ce03b0..1243007dfcf 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -7561,6 +7561,15 @@ }, "name": "matcher", "type": "array" + }, + { + "description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).", + "in": "query", + "items": { + "type": "string" + }, + "name": "rule_matcher", + "type": "array" } ], "responses": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index adb65e92ad9..81e0aa894c9 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1947,6 +1947,15 @@ "description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.", "name": "matcher", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).", + "name": "rule_matcher", + "in": "query" } ], "responses": { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 14da686b32f..f7bb3d9fcfd 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -18,6 +18,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/prometheus/alertmanager/pkg/labels" prommodels "github.com/prometheus/common/model" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -1012,6 +1013,10 @@ type ListAlertRulesQuery struct { SearchRuleGroup string HasPrometheusRuleDefinition *bool + + // LabelMatchers filters rules by their labels. + // Only equality and inequality matchers are supported, no regex operators. + LabelMatchers labels.Matchers } type ListAlertRulesExtendedQuery struct { diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 649af476741..4307778f9c6 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/prometheus/alertmanager/pkg/labels" "golang.org/x/exp/maps" "github.com/grafana/grafana/pkg/util/xorm" @@ -802,6 +803,15 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L return result, nextToken, err } +func matchersMatchLabels(matchers labels.Matchers, lbls map[string]string) bool { + for _, m := range matchers { + if !m.Matches(lbls[m.Name]) { + return false + } + } + return true +} + // nolint:gocyclo func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) { q = sess.Table("alert_rule") @@ -920,6 +930,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if len(query.LabelMatchers) > 0 { + q, err = st.filterByLabelMatchers(query.LabelMatchers, q) + if err != nil { + return nil, groupsSet, err + } + } + // FIXME: record is nullable but we don't save it as null when it's nil switch query.RuleType { case ngmodels.RuleTypeFilterAlerting: @@ -967,6 +984,11 @@ func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesE return nil, false } } + if len(query.LabelMatchers) > 0 { // remove false-positive hits from the result + if !matchersMatchLabels(query.LabelMatchers, converted.Labels) { + return nil, false + } + } // MySQL (and potentially other databases) can use case-insensitive comparison. // This code makes sure we return groups that only exactly match the filter. if groupsSet != nil { @@ -1355,6 +1377,23 @@ func (st DBstore) filterWithPrometheusRuleDefinition(value bool, sess *xorm.Sess ), nil } +// filterByLabelMatchers adds filtering for equality and inequality label matchers. +// Returns error if regex matchers are passed. +func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Session) (*xorm.Session, error) { + for _, m := range matchers { + if m.Type != labels.MatchEqual && m.Type != labels.MatchNotEqual { + return nil, fmt.Errorf("matcher %q %s %q is not supported", m.Name, m.Type, m.Value) + } + + sql, args, err := buildLabelMatcherCondition(st.SQLStore.GetDialect(), "labels", m) + if err != nil { + return nil, err + } + sess = sess.And(sql, args...) + } + return sess, nil +} + func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) { // fetch entire rules because Update method requires it because it copies rules to version table rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ diff --git a/pkg/services/ngalert/store/alert_rule_labels.go b/pkg/services/ngalert/store/alert_rule_labels.go new file mode 100644 index 00000000000..721071d5219 --- /dev/null +++ b/pkg/services/ngalert/store/alert_rule_labels.go @@ -0,0 +1,51 @@ +package store + +import ( + "fmt" + + "github.com/prometheus/alertmanager/pkg/labels" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// buildLabelMatcherCondition builds SQL for a label matcher with Prometheus semantics. +// For MySQL/PostgreSQL, it uses JSON functions, and +// for SQLite, it uses GLOB patterns to find matching labels. +func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return buildLabelMatcherGlob(column, m) + } + return buildLabelMatcherJSON(dialect, column, m) +} + +func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) { + switch { + case m.Type == labels.MatchEqual && m.Value == "": + eqSQL, eqArgs, _ := globEquals(column, m.Name, "") + missingSQL, missingArgs, _ := globKeyMissing(column, m.Name) + return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil + case m.Type == labels.MatchEqual: + return globEquals(column, m.Name, m.Value) + case m.Type == labels.MatchNotEqual: + return globNotEquals(column, m.Name, m.Value) + default: + return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type) + } +} + +func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) { + switch { + case m.Type == labels.MatchEqual && m.Value == "": + eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "") + missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name) + return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil + case m.Type == labels.MatchEqual: + sql, args := jsonEquals(dialect, column, m.Name, m.Value) + return sql, args, nil + case m.Type == labels.MatchNotEqual: + sql, args := jsonNotEquals(dialect, column, m.Name, m.Value) + return sql, args, nil + default: + return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go new file mode 100644 index 00000000000..9b72d8f00f9 --- /dev/null +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -0,0 +1,136 @@ +package store + +import ( + "testing" + + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func TestBuildLabelMatcherGlob(t *testing.T) { + tests := []struct { + name string + matcher *labels.Matcher + wantSQL string + wantArgs []any + wantErr bool + errContains string + }{ + { + name: "MatchEqual with non-empty value", + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "labels GLOB ?", + wantArgs: []any{`*"team":"alerting"*`}, + }, + { + name: "MatchEqual with empty value (Prometheus semantics)", + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: `(labels GLOB ? OR labels NOT GLOB ?)`, + wantArgs: []any{`*"team":""*`, `*"team":*`}, + }, + { + name: "MatchNotEqual", + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "labels NOT GLOB ?", + wantArgs: []any{`*"team":"alerting"*`}, + }, + { + name: "unsupported matcher type", + matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"}, + wantErr: true, + errContains: "unsupported matcher type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelMatcherGlob("labels", tt.matcher) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestBuildLabelMatcherJSON(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + matcher *labels.Matcher + wantSQL string + wantArgs []any + wantErr bool + errContains string + }{ + { + name: "MySQL MatchEqual with non-empty value", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "MySQL MatchEqual with empty value", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)", + wantArgs: []any{"team", "", "team"}, + }, + { + name: "MySQL MatchNotEqual", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "PostgreSQL MatchEqual with non-empty value", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "PostgreSQL MatchEqual with empty value", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)", + wantArgs: []any{"team", "", "team"}, + }, + { + name: "PostgreSQL MatchNotEqual", + dialect: migrator.NewPostgresDialect(), + matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "unsupported matcher type", + dialect: migrator.NewMysqlDialect(), + matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"}, + wantErr: true, + errContains: "unsupported matcher type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelMatcherJSON(tt.dialect, "labels", tt.matcher) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 93f6f9e57a4..91db9edc32a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -13,6 +13,7 @@ import ( "github.com/benbjohnson/clock" "github.com/google/uuid" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -2385,6 +2386,157 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by LabelMatchers", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + + ruleLower := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"team": "alerting", "severity": "warning"}), + ruleGen.WithTitle("rule_lowercase"))) + ruleUpper := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"team": "Alerting", "severity": "critical"}), + ruleGen.WithTitle("rule_uppercase"))) + ruleSpecial := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"key": `value"with"quotes`}), + ruleGen.WithTitle("rule_special"))) + ruleGlob := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"glob": "*[?]"}), + ruleGen.WithTitle("rule_glob"))) + ruleSpecialChars := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}), + ruleGen.WithTitle("rule_special_chars"))) + ruleEmpty := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"empty": ""}), + ruleGen.WithTitle("rule_empty"))) + ruleNonempty := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{"empty": "nonempty"}), + ruleGen.WithTitle("rule_nonempty"))) + + tc := []struct { + name string + labelMatchers labels.Matchers + expectedRules []*models.AlertRule + }{ + { + name: "equality matcher is case-sensitive", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleLower}, + }, + { + name: "equality matcher matches uppercase when specified", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper}, + }, + { + name: "inequality matcher is case-sensitive", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + }, + { + name: "special characters in labels are handled correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchEqual, "key", `value"with"quotes`) + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleSpecial}, + }, + { + name: "matcher with non-existent label returns no rules", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "nonexistent", "value"); return m }(), + }, + expectedRules: []*models.AlertRule{}, + }, + { + name: "multiple matchers are ANDed", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(), + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "severity", "critical"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleUpper}, + }, + { + name: "GLOB special characters are escaped correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "glob", "*[?]"); return m }(), + }, + expectedRules: []*models.AlertRule{ruleGlob}, + }, + { + name: "JSON escape characters are handled correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote") + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleSpecialChars}, + }, + { + name: "empty string value matches correctly", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(), + }, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty}, + }, + { + name: "inequality matcher on non-existent label matches all rules", + labelMatchers: labels.Matchers{ + func() *labels.Matcher { + m, _ := labels.NewMatcher(labels.MatchNotEqual, "nonexistent", "value") + return m + }(), + }, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: tt.labelMatchers, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + + t.Run("regex matcher returns error from store", func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchRegexp, "team", "alert.*"); return m }(), + }, + } + _, err := store.ListAlertRules(context.Background(), query) + require.Error(t, err) + require.ErrorContains(t, err, "is not supported") + }) + + t.Run("not-regex matcher returns error from store", func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + LabelMatchers: labels.Matchers{ + func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotRegexp, "team", "alert.*"); return m }(), + }, + } + _, err := store.ListAlertRules(context.Background(), query) + require.Error(t, err) + require.ErrorContains(t, err, "is not supported") + }) + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go new file mode 100644 index 00000000000..7b634246951 --- /dev/null +++ b/pkg/services/ngalert/store/json.go @@ -0,0 +1,101 @@ +package store + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// JSON functions for MySQL/PostgreSQL + +func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { + switch dialect.DriverName() { + case migrator.MySQL: + return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value} + case migrator.Postgres: + return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value} + default: + return "", nil + } +} + +func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { + var jx string + switch dialect.DriverName() { + case migrator.MySQL: + jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column) + case migrator.Postgres: + jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column) + default: + return "", nil + } + return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value} +} + +func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { + switch dialect.DriverName() { + case migrator.MySQL: + return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key} + case migrator.Postgres: + return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key} + default: + return "", nil + } +} + +// GLOB functions for SQLite + +func globEquals(column, key, value string) (string, []any, error) { + pattern, err := buildGlobPattern(key, value) + if err != nil { + return "", nil, err + } + return column + " GLOB ?", []any{"*" + pattern + "*"}, nil +} + +func globNotEquals(column, key, value string) (string, []any, error) { + pattern, err := buildGlobPattern(key, value) + if err != nil { + return "", nil, err + } + return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil +} + +func globKeyMissing(column, key string) (string, []any, error) { + pattern, err := buildGlobKeyPattern(key) + if err != nil { + return "", nil, err + } + return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil +} + +// Search for `"key":"value"` +func buildGlobPattern(key, value string) (string, error) { + keyJSON, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("failed to marshal key: %w", err) + } + valueJSON, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal value: %w", err) + } + return escapeGlobPattern(fmt.Sprintf(`%s:%s`, string(keyJSON), string(valueJSON))), nil +} + +// Search for `"key":` +func buildGlobKeyPattern(key string) (string, error) { + keyJSON, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("failed to marshal key: %w", err) + } + return escapeGlobPattern(string(keyJSON) + ":"), nil +} + +func escapeGlobPattern(pattern string) string { + pattern = strings.ReplaceAll(pattern, "[", "[[]") + pattern = strings.ReplaceAll(pattern, "*", "[*]") + pattern = strings.ReplaceAll(pattern, "?", "[?]") + return pattern +} diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go new file mode 100644 index 00000000000..89f85a027a6 --- /dev/null +++ b/pkg/services/ngalert/store/json_test.go @@ -0,0 +1,185 @@ +package store + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/stretchr/testify/require" +) + +func TestJsonEquals(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + value string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantArgs: []any{"team", "alerting"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantArgs: []any{"team", "alerting"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonEquals(tt.dialect, tt.column, tt.key, tt.value) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonNotEquals(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + value string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + value: "alerting", + wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantArgs: []any{"team", "team", "alerting"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonNotEquals(tt.dialect, tt.column, tt.key, tt.value) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonKeyMissing(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "team", + wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL", + wantArgs: []any{"team"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "team", + wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL", + wantArgs: []any{"team"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestGlobEquals(t *testing.T) { + sql, args, err := globEquals("labels", "team", "alerting") + require.NoError(t, err) + require.Equal(t, "labels GLOB ?", sql) + require.Equal(t, []any{`*"team":"alerting"*`}, args) +} + +func TestGlobNotEquals(t *testing.T) { + sql, args, err := globNotEquals("labels", "team", "alerting") + require.NoError(t, err) + require.Equal(t, "labels NOT GLOB ?", sql) + require.Equal(t, []any{`*"team":"alerting"*`}, args) +} + +func TestGlobKeyMissing(t *testing.T) { + sql, args, err := globKeyMissing("labels", "team") + require.NoError(t, err) + require.Equal(t, "labels NOT GLOB ?", sql) + require.Equal(t, []any{`*"team":*`}, args) +} + +func TestBuildGlobPattern(t *testing.T) { + tests := []struct { + name string + key string + value string + expected string + }{ + { + name: "simple key-value", + key: "team", + value: "alerting", + expected: `"team":"alerting"`, + }, + { + name: "empty value", + key: "empty", + value: "", + expected: `"empty":""`, + }, + { + name: "special GLOB chars are escaped", + key: "key", + value: "*[?]", + expected: `"key":"[*][[][?]]"`, + }, + { + name: "special chars are escaped", + key: "key", + value: "line1\nline2\\end\"quote", + expected: `"key":"line1\nline2\\end\"quote"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pattern, err := buildGlobPattern(tt.key, tt.value) + require.NoError(t, err) + require.Equal(t, tt.expected, pattern) + }) + } +} diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index cfe790b9853..42e30ddb052 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -219,6 +219,7 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert RuleUIDs: q.RuleUIDs, ReceiverName: q.ReceiverName, HasPrometheusRuleDefinition: q.HasPrometheusRuleDefinition, + LabelMatchers: q.LabelMatchers, } ruleList, err := f.listAlertRules(query) @@ -355,6 +356,20 @@ func (f *RuleStore) listAlertRules(q *models.ListAlertRulesQuery) (models.RulesG if q.ReceiverName != "" && (len(r.NotificationSettings) < 1 || r.NotificationSettings[0].Receiver != q.ReceiverName) { continue } + + if len(q.LabelMatchers) > 0 { + matches := true + for _, m := range q.LabelMatchers { + if !m.Matches(r.Labels[m.Name]) { + matches = false + break + } + } + if !matches { + continue + } + } + copyR := models.CopyRule(r) ruleList = append(ruleList, copyR) } diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 61bc3195857..067372a3470 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "testing" "time" @@ -364,8 +365,6 @@ func TestIntegrationPrometheusRules(t *testing.T) { func TestIntegrationPrometheusRulesPagination(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - testinfra.SQLiteIntegrationTest(t) - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -388,23 +387,30 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) { require.NoError(t, err) // Create 3 rule groups with different numbers of rules - // Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules) + // Group 1: 5 rules with team=backend + // Group 2: 3 rules with team=frontend + // Group 3: 2 rules with team=platform for groupIdx := 1; groupIdx <= 3; groupIdx++ { var rulesCount int + var team string switch groupIdx { case 1: rulesCount = 5 + team = "backend" case 2: rulesCount = 3 + team = "frontend" case 3: rulesCount = 2 + team = "platform" } rules := make([]apimodels.PostableExtendedRuleNode, rulesCount) for i := 0; i < rulesCount; i++ { rules[i] = apimodels.PostableExtendedRuleNode{ ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, + For: &interval, + Labels: map[string]string{"team": team}, }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1), @@ -514,6 +520,61 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) require.Len(t, result.Data.RuleGroups, 0, "should return no groups") }) + + t.Run("with rule_matcher filter returns only matching rules", func(t *testing.T) { + matcher := url.QueryEscape(`{"name":"team","value":"frontend","isRegex":false,"isEqual":true}`) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Should only return group-2 (team=frontend, 3 rules) + foundGroups := []string{} + total := 0 + for _, group := range result.Data.RuleGroups { + foundGroups = append(foundGroups, group.Name) + total += len(group.Rules) + } + require.Equal(t, []string{"group-2"}, foundGroups) + require.Equal(t, 3, total) + }) + + t.Run("with rule_matcher regex filter", func(t *testing.T) { + // Filter with regex team=~plat.* (should match group-3 with team=platform) + matcher := url.QueryEscape(`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Should only return group-3 (team=platform matches plat.*) + foundGroups := []string{} + total := 0 + for _, group := range result.Data.RuleGroups { + foundGroups = append(foundGroups, group.Name) + total += len(group.Rules) + } + require.Equal(t, []string{"group-3"}, foundGroups) + require.Equal(t, 2, total) + }) } func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 3c4fe219dd0..12fa3aae223 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -47,6 +47,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -120,6 +122,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ 'search.rule_name': title, 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, + rule_matcher: ruleMatchers, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 12a46a87bdd..ab9404d4e52 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -1,9 +1,12 @@ import { testWithFeatureToggles } from 'test/test-utils'; +import { config } from '@grafana/runtime'; import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks'; import { RuleHealth } from '../../search/rulesSearchParser'; +import { pluginMeta, pluginMetaToPluginConfig } from '../../testSetup/plugins'; +import { SupportedPlugin } from '../../types/pluginBridges'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; @@ -416,19 +419,40 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); + it('should include ruleMatchers in backend filter when labels are provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); + + expect(backendFilter.ruleMatchers).toBeDefined(); + expect(backendFilter.ruleMatchers).toHaveLength(1); + expect(backendFilter.ruleMatchers).toEqual([ + '{"name":"severity","value":"critical","isRegex":false,"isEqual":true}', + ]); + }); + it('should still apply other frontend filters', () => { - const rule = mockGrafanaPromAlertingRule({ + // Set up test plugin as installed + config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]); + + const regularRule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage', labels: { severity: 'critical', team: 'ops' }, alerts: [], }); - // Label filter should still work on frontend - const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); - expect(frontendFilter.ruleMatches(rule)).toBe(false); + const pluginRule = mockGrafanaPromAlertingRule({ + name: 'Plugin Rule', + labels: { __grafana_origin: `plugin/${SupportedPlugin.Slo}` }, + alerts: [], + }); - const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); - expect(frontendFilter2.ruleMatches(rule)).toBe(true); + // Plugins filter should still work on frontend + const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); + + // Non-plugin rules should pass through + expect(frontendFilter.ruleMatches(regularRule)).toBe(true); + + // Plugin-provided rules should be filtered out + expect(frontendFilter.ruleMatches(pluginRule)).toBe(false); }); }); @@ -681,20 +705,7 @@ describe('grafana-managed rules', () => { expect(frontendFilter.groupMatches(group)).toBe(true); }); - it('should still apply always-frontend filters (labels, namespace)', () => { - const rule = mockGrafanaPromAlertingRule({ - name: 'High CPU Usage', - labels: { severity: 'critical' }, - alerts: [], - }); - - // Labels filter should still work - const { frontendFilter: labelFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] })); - expect(labelFilter.ruleMatches(rule)).toBe(false); - - const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] })); - expect(labelFilter2.ruleMatches(rule)).toBe(true); - + it('should still apply always-frontend filters (namespace)', () => { // Namespace filter should still work const group: PromRuleGroupDTO = { name: 'Test Group', @@ -791,9 +802,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); }); + it('should return false for labels (handled by backend when feature toggle is enabled)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); + }); + it('should return true for client-side only filters', () => { expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { @@ -816,12 +831,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: frontend-handled filters + // Should return true for: frontend-handled filters (labels, namespace, plugins) expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); }); }); @@ -840,12 +856,13 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace, labels) + // Should return true for: always-frontend filters only (namespace, plugins) expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); - // Should return false for: backend-handled dataSourceNames when feature toggles are enabled + // Should return false for: backend-handled filters when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index c0fd9fea4d8..96ee951ee37 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -1,8 +1,11 @@ +import { attempt, isError } from 'lodash'; + import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles'; import { RulesFilter } from '../../search/rulesSearchParser'; +import { parseMatcher } from '../../utils/matchers'; import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; import { @@ -75,6 +78,12 @@ export function getGrafanaFilter(filterState: Partial) { hasInvalidDataSourceNames = datasourceUids.length === 0; } + // Convert labels to JSON-encoded matchers for backend filtering + const ruleMatchersBackendFilter: string[] | undefined = + ruleFilterConfig.labels || normalizedFilterState.labels.length === 0 + ? undefined + : labelMatchersToBackendFormat(normalizedFilterState.labels); + const backendFilter: GrafanaPromRulesOptions = { state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], @@ -85,6 +94,7 @@ export function getGrafanaFilter(filterState: Partial) { dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid, searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, + ruleMatchers: ruleMatchersBackendFilter, }; return { @@ -115,7 +125,7 @@ function buildGrafanaFilterConfigs() { ruleState: null, ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter, dataSourceNames: useBackendFilters || useFullyCompatibleBackendFilters ? null : dataSourceNamesFilter, - labels: labelsFilter, + labels: useBackendFilters ? null : labelsFilter, ruleHealth: null, dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, plugins: pluginsFilter, @@ -129,3 +139,21 @@ function buildGrafanaFilterConfigs() { return { ruleFilterConfig, groupFilterConfig }; } + +/** + * Converts label matchers to JSON-encoded strings for backend filtering. + * Invalid matchers are logged and filtered out. + */ +function labelMatchersToBackendFormat(labels: string[]): string[] { + return labels.reduce((acc, label) => { + const result = attempt(() => JSON.stringify(parseMatcher(label))); + + if (isError(result)) { + console.warn('Failed to parse label matcher:', label, result); + } else { + acc.push(result); + } + + return acc; + }, []); +} diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts index 2e1db699883..5d6b7c97782 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -74,6 +74,7 @@ describe('paginationLimits', () => { { ruleHealth: RuleHealth.Ok }, { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -86,7 +87,6 @@ describe('paginationLimits', () => { it.each>([ { namespace: 'production' }, - { labels: ['severity=critical'] }, { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, ])('should return large limits for both when frontend filters are used: %p', (filterState) => { const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); @@ -157,6 +157,7 @@ describe('paginationLimits', () => { { ruleHealth: RuleHealth.Ok }, { contactPoint: 'slack' }, { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, ])( 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', (filterState) => { @@ -167,7 +168,7 @@ describe('paginationLimits', () => { } ); - it.each>([{ namespace: 'production' }, { labels: ['severity=critical'] }])( + it.each>([{ namespace: 'production' }])( 'should return large limits for both when frontend filters are used: %p', (filterState) => { const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); From 5ecfc79e149b0aeed1e612a82fe522ab2fb717fd Mon Sep 17 00:00:00 2001 From: Daniele Stefano Ferru Date: Tue, 16 Dec 2025 14:37:07 +0100 Subject: [PATCH 20/21] Provisioning: Add Connection resource (#115272) * Provisioning: Add Connection resource * adding some more integration tests * updating openapi snapshot, linting * generating FE code, fixing issue in unit tests * addressing comments * addressing comments * adding more integration tests * fixing rebase issues * removing linting exception * addressing comments: improving validation and tests * adding Connection URL at mutation time, updating tests accordingly * linting --- apps/provisioning/kinds/connection.cue | 73 + apps/provisioning/kinds/manifest.cue | 3 +- .../apis/provisioning/v0alpha1/connections.go | 118 ++ .../pkg/apis/provisioning/v0alpha1/health.go | 26 + .../apis/provisioning/v0alpha1/register.go | 43 + .../pkg/apis/provisioning/v0alpha1/types.go | 25 - .../v0alpha1/zz_generated.deepcopy.go | 177 +++ .../v0alpha1/zz_generated.openapi.go | 309 ++++ ...enerated.openapi_violation_exceptions.list | 3 + apps/provisioning/pkg/connection/mutator.go | 28 + .../pkg/connection/mutator_test.go | 35 + apps/provisioning/pkg/connection/validator.go | 104 ++ .../pkg/connection/validator_test.go | 253 +++ .../v0alpha1/bitbucketconnectionconfig.go | 25 + .../provisioning/v0alpha1/connection.go | 237 +++ .../provisioning/v0alpha1/connectionsecure.go | 47 + .../provisioning/v0alpha1/connectionspec.go | 65 + .../provisioning/v0alpha1/connectionstatus.go | 47 + .../v0alpha1/githubconnectionconfig.go | 34 + .../v0alpha1/gitlabconnectionconfig.go | 25 + .../pkg/generated/applyconfiguration/utils.go | 14 + .../typed/provisioning/v0alpha1/connection.go | 60 + .../v0alpha1/fake/fake_connection.go | 37 + .../v0alpha1/fake/fake_provisioning_client.go | 4 + .../v0alpha1/generated_expansion.go | 2 + .../v0alpha1/provisioning_client.go | 5 + .../informers/externalversions/generic.go | 2 + .../provisioning/v0alpha1/connection.go | 88 ++ .../provisioning/v0alpha1/interface.go | 7 + .../provisioning/v0alpha1/connection.go | 56 + .../v0alpha1/expansion_generated.go | 8 + .../provisioning/v0alpha1/endpoints.gen.ts | 624 ++++++-- .../controller/repository_test.go | 4 + pkg/registry/apis/provisioning/register.go | 44 + .../provisioning.grafana.app-v0alpha1.json | 1374 +++++++++++++++++ .../apis/provisioning/connection_test.go | 413 +++++ pkg/tests/apis/provisioning/helper_test.go | 7 + 37 files changed, 4305 insertions(+), 121 deletions(-) create mode 100644 apps/provisioning/kinds/connection.cue create mode 100644 apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go create mode 100644 apps/provisioning/pkg/apis/provisioning/v0alpha1/health.go create mode 100644 apps/provisioning/pkg/connection/mutator.go create mode 100644 apps/provisioning/pkg/connection/mutator_test.go create mode 100644 apps/provisioning/pkg/connection/validator.go create mode 100644 apps/provisioning/pkg/connection/validator_test.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/bitbucketconnectionconfig.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connection.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionspec.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionstatus.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubconnectionconfig.go create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/gitlabconnectionconfig.go create mode 100644 apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/connection.go create mode 100644 apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_connection.go create mode 100644 apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/connection.go create mode 100644 apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/connection.go create mode 100644 pkg/tests/apis/provisioning/connection_test.go diff --git a/apps/provisioning/kinds/connection.cue b/apps/provisioning/kinds/connection.cue new file mode 100644 index 00000000000..23af1991c02 --- /dev/null +++ b/apps/provisioning/kinds/connection.cue @@ -0,0 +1,73 @@ +package repository + +connection: { + kind: "Connection" + pluralName: "Connections" + current: "v0alpha1" + validation: { + operations: [ + "CREATE", + "UPDATE", + ] + } + versions: { + "v0alpha1": { + codegen: { + ts: {enabled: false} + go: {enabled: true} + } + schema: { + #GitHubConnectionConfig: { + // App-level information + // GitHub App ID + appID: int + + // Installation-level information + // GitHub App installation ID + installationID: int + } + #BitbucketConnectionConfig: { + // The app clientID + clientID: string + } + #GitlabConnectionConfig: { + // The app clientID + clientID: string + } + #HealthStatus: { + // When not healthy, requests will not be executed + healthy: bool + // When the health was checked last time + checked?: int + // Summary messages (can be shown to users) + // Will only be populated when not healthy + message?: [...string] + } + spec: { + // The connection provider type + type: "github" | "bitbucket" | "gitlab" + // The connection URL + url: *"" | string + // GitHub connection configuration + // Only applicable when provider is "github" + github?: #GitHubConnectionConfig + // Bitbucket connection configuration + // Only applicable when provider is "bitbucket" + bitbucket?: #BitbucketConnectionConfig + // Gitlab connection configuration + // Only applicable when provider is "gitlab" + gitlab?: #GitlabConnectionConfig + } + status: { + // The generation of the spec last time reconciliation ran + observedGeneration?: int + // Connection state + state: "connected" | "disconnected" + // The connection health status + health: #HealthStatus + } + } + } + } +} + diff --git a/apps/provisioning/kinds/manifest.cue b/apps/provisioning/kinds/manifest.cue index 40ffa64d922..d0d751dd62b 100644 --- a/apps/provisioning/kinds/manifest.cue +++ b/apps/provisioning/kinds/manifest.cue @@ -5,5 +5,6 @@ manifest: { groupOverride: "provisioning.grafana.app" kinds: [ repository, + connection ] -} \ No newline at end of file +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go new file mode 100644 index 00000000000..228523f598e --- /dev/null +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -0,0 +1,118 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// When this code is changed, make sure to update the code generation. +// As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning +// If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Connection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ConnectionSpec `json:"spec,omitempty"` + Secure ConnectionSecure `json:"secure,omitzero,omitempty"` + Status ConnectionStatus `json:"status,omitempty"` +} + +type ConnectionSecure struct { + // PrivateKey is the reference to the private key used for GitHub App authentication. + // This value is stored securely and cannot be read back + PrivateKey common.InlineSecureValue `json:"privateKey,omitzero,omitempty"` + + // ClientSecret is the reference to the secret used for other providers authentication, + // and Github on-behalf-of authentication. + // This value is stored securely and cannot be read back + ClientSecret common.InlineSecureValue `json:"clientSecret,omitzero,omitempty"` + + // Token is the reference of the token used to act as the Connection. + // This value is stored securely and cannot be read back + Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"` +} + +func (v ConnectionSecure) IsZero() bool { + return v.PrivateKey.IsZero() && v.Token.IsZero() +} + +type GitHubConnectionConfig struct { + // GitHub App ID + AppID string `json:"appID"` + + // GitHub App installation ID + InstallationID string `json:"installationID"` +} + +type BitbucketConnectionConfig struct { + // App client ID + ClientID string `json:"clientID"` +} + +type GitlabConnectionConfig struct { + // App client ID + ClientID string `json:"clientID"` +} + +// ConnectionType defines the types of Connection providers +// +enum +type ConnectionType string + +// ConnectionType values. +const ( + GithubConnectionType ConnectionType = "github" + GitlabConnectionType ConnectionType = "gitlab" + BitbucketConnectionType ConnectionType = "bitbucket" +) + +type ConnectionSpec struct { + // The connection provider type + Type ConnectionType `json:"type"` + // The connection URL + URL string `json:"url,omitempty"` + + // GitHub connection configuration + // Only applicable when provider is "github" + GitHub *GitHubConnectionConfig `json:"github,omitempty"` + // Bitbucket connection configuration + // Only applicable when provider is "bitbucket" + Bitbucket *BitbucketConnectionConfig `json:"bitbucket,omitempty"` + // Gitlab connection configuration + // Only applicable when provider is "gitlab" + Gitlab *GitlabConnectionConfig `json:"gitlab,omitempty"` +} + +// ConnectionState defines the state of a Connection +// +enum +type ConnectionState string + +// ConnectionState values +const ( + ConnectionStateConnected ConnectionState = "connected" + ConnectionStateDisconnected ConnectionState = "disconnected" +) + +// The status of a Connection. +// This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually. +type ConnectionStatus struct { + // The generation of the spec last time reconciliation ran + ObservedGeneration int64 `json:"observedGeneration"` + + // Connection state + State ConnectionState `json:"state"` + + // The connection health status + Health HealthStatus `json:"health"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + // +listType=atomic + Items []Connection `json:"items"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/health.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/health.go new file mode 100644 index 00000000000..1298580c9a9 --- /dev/null +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/health.go @@ -0,0 +1,26 @@ +package v0alpha1 + +// HealthFailureType represents different types of healthcheck failures +// +enum +type HealthFailureType string + +const ( + HealthFailureHook HealthFailureType = "hook" + HealthFailureHealth HealthFailureType = "health" +) + +type HealthStatus struct { + // When not healthy, requests will not be executed + Healthy bool `json:"healthy"` + + // The type of the error + Error HealthFailureType `json:"error,omitempty"` + + // When the health was checked last time + Checked int64 `json:"checked,omitempty"` + + // Summary messages (can be shown to users) + // Will only be populated when not healthy + // +listType=atomic + Message []string `json:"message,omitempty"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index 777484828f6..f06798c0ddd 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -115,6 +115,47 @@ var HistoricJobResourceInfo = utils.NewResourceInfo(GROUP, VERSION, }, }) +var ConnectionResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "connections", "connection", "Connection", + func() runtime.Object { return &Connection{} }, // newObj + func() runtime.Object { return &ConnectionList{} }, // newList + utils.TableColumns{ // Returned by `kubectl get`. Doesn't affect disk storage. + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + {Name: "Type", Type: "string"}, + {Name: "AppID", Type: "string"}, + {Name: "InstallationID", Type: "string"}, + {Name: "ClientID", Type: "string"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*Connection) + if !ok { + return nil, errors.New("expected Repository") + } + + var appID, installationID, clientID string + switch m.Spec.Type { + case GithubConnectionType: + appID = m.Spec.GitHub.AppID + installationID = m.Spec.GitHub.InstallationID + case BitbucketConnectionType: + clientID = m.Spec.Bitbucket.ClientID + case GitlabConnectionType: + clientID = m.Spec.Gitlab.ClientID + } + + return []interface{}{ + m.Name, + m.CreationTimestamp.UTC().Format(time.RFC3339), + m.Spec.Type, + appID, + installationID, + clientID, + }, nil + }, + }) + var ( // SchemeGroupVersion is group version used to register these objects SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} @@ -154,6 +195,8 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &RefList{}, &HistoricJob{}, &HistoricJobList{}, + &Connection{}, + &ConnectionList{}, ) return nil } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go index dff7c3813e5..a4c4f0ef643 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go @@ -315,31 +315,6 @@ type RepositoryStatus struct { DeleteError string `json:"deleteError,omitempty"` } -// HealthFailureType represents different types of repository failures -// +enum -type HealthFailureType string - -const ( - HealthFailureHook HealthFailureType = "hook" - HealthFailureHealth HealthFailureType = "health" -) - -type HealthStatus struct { - // When not healthy, requests will not be executed - Healthy bool `json:"healthy"` - - // The type of the error - Error HealthFailureType `json:"error,omitempty"` - - // When the health was checked last time - Checked int64 `json:"checked,omitempty"` - - // Summary messages (can be shown to users) - // Will only be populated when not healthy - // +listType=atomic - Message []string `json:"message,omitempty"` -} - type SyncStatus struct { // pending, running, success, error State JobState `json:"state"` diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 4bf4f7674ff..6ca50fdd277 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitbucketConnectionConfig) DeepCopyInto(out *BitbucketConnectionConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketConnectionConfig. +func (in *BitbucketConnectionConfig) DeepCopy() *BitbucketConnectionConfig { + if in == nil { + return nil + } + out := new(BitbucketConnectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BitbucketRepositoryConfig) DeepCopyInto(out *BitbucketRepositoryConfig) { *out = *in @@ -43,6 +59,135 @@ func (in *BitbucketRepositoryConfig) DeepCopy() *BitbucketRepositoryConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Connection) DeepCopyInto(out *Connection) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Secure = in.Secure + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Connection. +func (in *Connection) DeepCopy() *Connection { + if in == nil { + return nil + } + out := new(Connection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Connection) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionList) DeepCopyInto(out *ConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Connection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionList. +func (in *ConnectionList) DeepCopy() *ConnectionList { + if in == nil { + return nil + } + out := new(ConnectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ConnectionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionSecure) DeepCopyInto(out *ConnectionSecure) { + *out = *in + out.PrivateKey = in.PrivateKey + out.ClientSecret = in.ClientSecret + out.Token = in.Token + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSecure. +func (in *ConnectionSecure) DeepCopy() *ConnectionSecure { + if in == nil { + return nil + } + out := new(ConnectionSecure) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionSpec) DeepCopyInto(out *ConnectionSpec) { + *out = *in + if in.GitHub != nil { + in, out := &in.GitHub, &out.GitHub + *out = new(GitHubConnectionConfig) + **out = **in + } + if in.Bitbucket != nil { + in, out := &in.Bitbucket, &out.Bitbucket + *out = new(BitbucketConnectionConfig) + **out = **in + } + if in.Gitlab != nil { + in, out := &in.Gitlab, &out.Gitlab + *out = new(GitlabConnectionConfig) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSpec. +func (in *ConnectionSpec) DeepCopy() *ConnectionSpec { + if in == nil { + return nil + } + out := new(ConnectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionStatus) DeepCopyInto(out *ConnectionStatus) { + *out = *in + in.Health.DeepCopyInto(&out.Health) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionStatus. +func (in *ConnectionStatus) DeepCopy() *ConnectionStatus { + if in == nil { + return nil + } + out := new(ConnectionStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DeleteJobOptions) DeepCopyInto(out *DeleteJobOptions) { *out = *in @@ -148,6 +293,22 @@ func (in *FileList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubConnectionConfig) DeepCopyInto(out *GitHubConnectionConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubConnectionConfig. +func (in *GitHubConnectionConfig) DeepCopy() *GitHubConnectionConfig { + if in == nil { + return nil + } + out := new(GitHubConnectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHubRepositoryConfig) DeepCopyInto(out *GitHubRepositoryConfig) { *out = *in @@ -196,6 +357,22 @@ func (in *GitRepositoryConfig) DeepCopy() *GitRepositoryConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitlabConnectionConfig) DeepCopyInto(out *GitlabConnectionConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitlabConnectionConfig. +func (in *GitlabConnectionConfig) DeepCopy() *GitlabConnectionConfig { + if in == nil { + return nil + } + out := new(GitlabConnectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HealthStatus) DeepCopyInto(out *HealthStatus) { *out = *in diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 933525eca0b..0e1a224d457 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -15,15 +15,23 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_BitbucketConnectionConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection": schema_pkg_apis_provisioning_v0alpha1_Connection(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionList": schema_pkg_apis_provisioning_v0alpha1_ConnectionList(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure": schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec": schema_pkg_apis_provisioning_v0alpha1_ConnectionSpec(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus": schema_pkg_apis_provisioning_v0alpha1_ConnectionStatus(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.DeleteJobOptions": schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitLabRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitLabRepositoryConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitlabConnectionConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus": schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJob": schema_pkg_apis_provisioning_v0alpha1_HistoricJob(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJobList": schema_pkg_apis_provisioning_v0alpha1_HistoricJobList(ref), @@ -100,6 +108,27 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback) } } +func schema_pkg_apis_provisioning_v0alpha1_BitbucketConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "clientID": { + SchemaProps: spec.SchemaProps{ + Description: "App client ID", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"clientID"}, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -142,6 +171,236 @@ func schema_pkg_apis_provisioning_v0alpha1_BitbucketRepositoryConfig(ref common. } } +func schema_pkg_apis_provisioning_v0alpha1_Connection(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "When this code is changed, make sure to update the code generation. As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec"), + }, + }, + "secure": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSecure", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionSpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ConnectionStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection"), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Connection", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "privateKey": { + SchemaProps: spec.SchemaProps{ + Description: "PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + "clientSecret": { + SchemaProps: spec.SchemaProps{ + Description: "ClientSecret is the reference to the secret used for other providers authentication, and Github on-behalf-of authentication. This value is stored securely and cannot be read back", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + "webhook": { + SchemaProps: spec.SchemaProps{ + Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"}, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ConnectionSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Description: "The connection provider type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"github\"`\n - `\"gitlab\"`", + Default: "", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"bitbucket", "github", "gitlab"}, + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "The connection URL", + Type: []string{"string"}, + Format: "", + }, + }, + "github": { + SchemaProps: spec.SchemaProps{ + Description: "GitHub connection configuration Only applicable when provider is \"github\"", + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig"), + }, + }, + "bitbucket": { + SchemaProps: spec.SchemaProps{ + Description: "Bitbucket connection configuration Only applicable when provider is \"bitbucket\"", + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig"), + }, + }, + "gitlab": { + SchemaProps: spec.SchemaProps{ + Description: "Gitlab connection configuration Only applicable when provider is \"gitlab\"", + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig"), + }, + }, + }, + Required: []string{"type"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.BitbucketConnectionConfig", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitlabConnectionConfig"}, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ConnectionStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "The status of a Connection. This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "observedGeneration": { + SchemaProps: spec.SchemaProps{ + Description: "The generation of the spec last time reconciliation ran", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "state": { + SchemaProps: spec.SchemaProps{ + Description: "Connection state\n\nPossible enum values:\n - `\"connected\"`\n - `\"disconnected\"`", + Default: "", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"connected", "disconnected"}, + }, + }, + "health": { + SchemaProps: spec.SchemaProps{ + Description: "The connection health status", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus"), + }, + }, + }, + Required: []string{"observedGeneration", "state", "health"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus"}, + } +} + func schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -362,6 +621,35 @@ func schema_pkg_apis_provisioning_v0alpha1_FileList(ref common.ReferenceCallback } } +func schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "appID": { + SchemaProps: spec.SchemaProps{ + Description: "GitHub App ID", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "installationID": { + SchemaProps: spec.SchemaProps{ + Description: "GitHub App installation ID", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"appID", "installationID"}, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -481,6 +769,27 @@ func schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref common.Refere } } +func schema_pkg_apis_provisioning_v0alpha1_GitlabConnectionConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "clientID": { + SchemaProps: spec.SchemaProps{ + Description: "App client ID", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"clientID"}, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index 53071a05ec8..b9504855b80 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,3 +1,4 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items @@ -20,6 +21,8 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents +API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token +API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Identity diff --git a/apps/provisioning/pkg/connection/mutator.go b/apps/provisioning/pkg/connection/mutator.go new file mode 100644 index 00000000000..30291669905 --- /dev/null +++ b/apps/provisioning/pkg/connection/mutator.go @@ -0,0 +1,28 @@ +package connection + +import ( + "fmt" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +const ( + githubInstallationURL = "https://github.com/settings/installations" +) + +func MutateConnection(connection *provisioning.Connection) error { + switch connection.Spec.Type { + case provisioning.GithubConnectionType: + // Do nothing in case spec.Github is nil. + // If this field is required, we should fail at validation time. + if connection.Spec.GitHub == nil { + return nil + } + + connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID) + return nil + default: + // TODO: we need to setup the URL for bitbucket and gitlab. + return nil + } +} diff --git a/apps/provisioning/pkg/connection/mutator_test.go b/apps/provisioning/pkg/connection/mutator_test.go new file mode 100644 index 00000000000..a25aabd10a1 --- /dev/null +++ b/apps/provisioning/pkg/connection/mutator_test.go @@ -0,0 +1,35 @@ +package connection_test + +import ( + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutateConnection(t *testing.T) { + t.Run("should add URL to Github connection", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + } + + require.NoError(t, connection.MutateConnection(c)) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + }) +} diff --git a/apps/provisioning/pkg/connection/validator.go b/apps/provisioning/pkg/connection/validator.go new file mode 100644 index 00000000000..c2537e3af2f --- /dev/null +++ b/apps/provisioning/pkg/connection/validator.go @@ -0,0 +1,104 @@ +package connection + +import ( + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateConnection(connection *provisioning.Connection) error { + list := field.ErrorList{} + + if connection.Spec.Type == "" { + list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified")) + } + + switch connection.Spec.Type { + case provisioning.GithubConnectionType: + list = append(list, validateGithubConnection(connection)...) + case provisioning.BitbucketConnectionType: + list = append(list, validateBitbucketConnection(connection)...) + case provisioning.GitlabConnectionType: + list = append(list, validateGitlabConnection(connection)...) + default: + list = append( + list, field.NotSupported( + field.NewPath("spec", "type"), + connection.Spec.Type, + []provisioning.ConnectionType{ + provisioning.GithubConnectionType, + provisioning.BitbucketConnectionType, + provisioning.GitlabConnectionType, + }), + ) + } + + return toError(connection.GetName(), list) +} + +func validateGithubConnection(connection *provisioning.Connection) field.ErrorList { + list := field.ErrorList{} + + if connection.Spec.GitHub == nil { + list = append( + list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), + ) + } + + if connection.Secure.PrivateKey.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) + } + if !connection.Secure.ClientSecret.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) + } + + return list +} + +func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList { + list := field.ErrorList{} + + if connection.Spec.Bitbucket == nil { + list = append( + list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"), + ) + } + if connection.Secure.ClientSecret.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection")) + } + if !connection.Secure.PrivateKey.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection")) + } + + return list +} + +func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList { + list := field.ErrorList{} + + if connection.Spec.Gitlab == nil { + list = append( + list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"), + ) + } + if connection.Secure.ClientSecret.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection")) + } + if !connection.Secure.PrivateKey.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection")) + } + + return list +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), + name, + list, + ) +} diff --git a/apps/provisioning/pkg/connection/validator_test.go b/apps/provisioning/pkg/connection/validator_test.go new file mode 100644 index 00000000000..23d4b01b800 --- /dev/null +++ b/apps/provisioning/pkg/connection/validator_test.go @@ -0,0 +1,253 @@ +package connection_test + +import ( + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestValidateConnection(t *testing.T) { + tests := []struct { + name string + connection *provisioning.Connection + wantErr bool + errMsg string + }{ + { + name: "empty type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{}, + }, + wantErr: true, + errMsg: "spec.type", + }, + { + name: "invalid type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: "invalid", + }, + }, + wantErr: true, + errMsg: "spec.type", + }, + { + name: "github type without github config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + }, + wantErr: true, + errMsg: "spec.github", + }, + { + name: "github type without private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + }, + wantErr: true, + errMsg: "secure.privateKey", + }, + { + name: "github type with client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + ClientSecret: common.InlineSecureValue{ + Name: "test-client-secret", + }, + }, + }, + wantErr: true, + errMsg: "secure.clientSecret", + }, + { + name: "github type with github config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + }, + wantErr: false, + }, + { + name: "bitbucket type without bitbucket config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.BitbucketConnectionType, + }, + }, + wantErr: true, + errMsg: "spec.bitbucket", + }, + { + name: "bitbucket type without client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.BitbucketConnectionType, + Bitbucket: &provisioning.BitbucketConnectionConfig{ + ClientID: "client-123", + }, + }, + }, + wantErr: true, + errMsg: "secure.clientSecret", + }, + { + name: "bitbucket type with private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.BitbucketConnectionType, + Bitbucket: &provisioning.BitbucketConnectionConfig{ + ClientID: "client-123", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + ClientSecret: common.InlineSecureValue{ + Name: "test-client-secret", + }, + }, + }, + wantErr: true, + errMsg: "secure.privateKey", + }, + { + name: "bitbucket type with bitbucket config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.BitbucketConnectionType, + Bitbucket: &provisioning.BitbucketConnectionConfig{ + ClientID: "client-123", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Name: "test-client-secret", + }, + }, + }, + wantErr: false, + }, + { + name: "gitlab type without gitlab config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + }, + wantErr: true, + errMsg: "spec.gitlab", + }, + { + name: "gitlab type without client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "client-456", + }, + }, + }, + wantErr: true, + errMsg: "secure.clientSecret", + }, + { + name: "gitlab type with private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "client-456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + ClientSecret: common.InlineSecureValue{ + Name: "test-client-secret", + }, + }, + }, + wantErr: true, + errMsg: "secure.privateKey", + }, + { + name: "gitlab type with gitlab config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "client-456", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Name: "test-client-secret", + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := connection.ValidateConnection(tt.connection) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/bitbucketconnectionconfig.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/bitbucketconnectionconfig.go new file mode 100644 index 00000000000..9a3604afe71 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/bitbucketconnectionconfig.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// BitbucketConnectionConfigApplyConfiguration represents a declarative configuration of the BitbucketConnectionConfig type for use +// with apply. +type BitbucketConnectionConfigApplyConfiguration struct { + ClientID *string `json:"clientID,omitempty"` +} + +// BitbucketConnectionConfigApplyConfiguration constructs a declarative configuration of the BitbucketConnectionConfig type for use with +// apply. +func BitbucketConnectionConfig() *BitbucketConnectionConfigApplyConfiguration { + return &BitbucketConnectionConfigApplyConfiguration{} +} + +// WithClientID sets the ClientID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientID field is set to the value of the last call. +func (b *BitbucketConnectionConfigApplyConfiguration) WithClientID(value string) *BitbucketConnectionConfigApplyConfiguration { + b.ClientID = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connection.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connection.go new file mode 100644 index 00000000000..a7a56c62a14 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connection.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// ConnectionApplyConfiguration represents a declarative configuration of the Connection type for use +// with apply. +type ConnectionApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *ConnectionSpecApplyConfiguration `json:"spec,omitempty"` + Secure *ConnectionSecureApplyConfiguration `json:"secure,omitempty"` + Status *ConnectionStatusApplyConfiguration `json:"status,omitempty"` +} + +// Connection constructs a declarative configuration of the Connection type for use with +// apply. +func Connection(name, namespace string) *ConnectionApplyConfiguration { + b := &ConnectionApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("Connection") + b.WithAPIVersion("provisioning.grafana.app/v0alpha1") + return b +} +func (b ConnectionApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithKind(value string) *ConnectionApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithAPIVersion(value string) *ConnectionApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithName(value string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithGenerateName(value string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithNamespace(value string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithUID(value types.UID) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithResourceVersion(value string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithGeneration(value int64) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithCreationTimestamp(value metav1.Time) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *ConnectionApplyConfiguration) WithLabels(entries map[string]string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *ConnectionApplyConfiguration) WithAnnotations(entries map[string]string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *ConnectionApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *ConnectionApplyConfiguration) WithFinalizers(values ...string) *ConnectionApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *ConnectionApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithSpec(value *ConnectionSpecApplyConfiguration) *ConnectionApplyConfiguration { + b.Spec = value + return b +} + +// WithSecure sets the Secure field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Secure field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithSecure(value *ConnectionSecureApplyConfiguration) *ConnectionApplyConfiguration { + b.Secure = value + return b +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *ConnectionApplyConfiguration) WithStatus(value *ConnectionStatusApplyConfiguration) *ConnectionApplyConfiguration { + b.Status = value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *ConnectionApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *ConnectionApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *ConnectionApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *ConnectionApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go new file mode 100644 index 00000000000..8ac26b192c9 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// ConnectionSecureApplyConfiguration represents a declarative configuration of the ConnectionSecure type for use +// with apply. +type ConnectionSecureApplyConfiguration struct { + PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"` + ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"` + Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"` +} + +// ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with +// apply. +func ConnectionSecure() *ConnectionSecureApplyConfiguration { + return &ConnectionSecureApplyConfiguration{} +} + +// WithPrivateKey sets the PrivateKey field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PrivateKey field is set to the value of the last call. +func (b *ConnectionSecureApplyConfiguration) WithPrivateKey(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration { + b.PrivateKey = &value + return b +} + +// WithClientSecret sets the ClientSecret field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientSecret field is set to the value of the last call. +func (b *ConnectionSecureApplyConfiguration) WithClientSecret(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration { + b.ClientSecret = &value + return b +} + +// WithToken sets the Token field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Token field is set to the value of the last call. +func (b *ConnectionSecureApplyConfiguration) WithToken(value commonv0alpha1.InlineSecureValue) *ConnectionSecureApplyConfiguration { + b.Token = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionspec.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionspec.go new file mode 100644 index 00000000000..1b55b832ae1 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionspec.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// ConnectionSpecApplyConfiguration represents a declarative configuration of the ConnectionSpec type for use +// with apply. +type ConnectionSpecApplyConfiguration struct { + Type *provisioningv0alpha1.ConnectionType `json:"type,omitempty"` + URL *string `json:"url,omitempty"` + GitHub *GitHubConnectionConfigApplyConfiguration `json:"github,omitempty"` + Bitbucket *BitbucketConnectionConfigApplyConfiguration `json:"bitbucket,omitempty"` + Gitlab *GitlabConnectionConfigApplyConfiguration `json:"gitlab,omitempty"` +} + +// ConnectionSpecApplyConfiguration constructs a declarative configuration of the ConnectionSpec type for use with +// apply. +func ConnectionSpec() *ConnectionSpecApplyConfiguration { + return &ConnectionSpecApplyConfiguration{} +} + +// WithType sets the Type field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Type field is set to the value of the last call. +func (b *ConnectionSpecApplyConfiguration) WithType(value provisioningv0alpha1.ConnectionType) *ConnectionSpecApplyConfiguration { + b.Type = &value + return b +} + +// WithURL sets the URL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the URL field is set to the value of the last call. +func (b *ConnectionSpecApplyConfiguration) WithURL(value string) *ConnectionSpecApplyConfiguration { + b.URL = &value + return b +} + +// WithGitHub sets the GitHub field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GitHub field is set to the value of the last call. +func (b *ConnectionSpecApplyConfiguration) WithGitHub(value *GitHubConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration { + b.GitHub = value + return b +} + +// WithBitbucket sets the Bitbucket field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Bitbucket field is set to the value of the last call. +func (b *ConnectionSpecApplyConfiguration) WithBitbucket(value *BitbucketConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration { + b.Bitbucket = value + return b +} + +// WithGitlab sets the Gitlab field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Gitlab field is set to the value of the last call. +func (b *ConnectionSpecApplyConfiguration) WithGitlab(value *GitlabConnectionConfigApplyConfiguration) *ConnectionSpecApplyConfiguration { + b.Gitlab = value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionstatus.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionstatus.go new file mode 100644 index 00000000000..b9d510aac91 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionstatus.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// ConnectionStatusApplyConfiguration represents a declarative configuration of the ConnectionStatus type for use +// with apply. +type ConnectionStatusApplyConfiguration struct { + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` + State *provisioningv0alpha1.ConnectionState `json:"state,omitempty"` + Health *HealthStatusApplyConfiguration `json:"health,omitempty"` +} + +// ConnectionStatusApplyConfiguration constructs a declarative configuration of the ConnectionStatus type for use with +// apply. +func ConnectionStatus() *ConnectionStatusApplyConfiguration { + return &ConnectionStatusApplyConfiguration{} +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *ConnectionStatusApplyConfiguration) WithObservedGeneration(value int64) *ConnectionStatusApplyConfiguration { + b.ObservedGeneration = &value + return b +} + +// WithState sets the State field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the State field is set to the value of the last call. +func (b *ConnectionStatusApplyConfiguration) WithState(value provisioningv0alpha1.ConnectionState) *ConnectionStatusApplyConfiguration { + b.State = &value + return b +} + +// WithHealth sets the Health field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Health field is set to the value of the last call. +func (b *ConnectionStatusApplyConfiguration) WithHealth(value *HealthStatusApplyConfiguration) *ConnectionStatusApplyConfiguration { + b.Health = value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubconnectionconfig.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubconnectionconfig.go new file mode 100644 index 00000000000..c2f2a8b291b --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubconnectionconfig.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// GitHubConnectionConfigApplyConfiguration represents a declarative configuration of the GitHubConnectionConfig type for use +// with apply. +type GitHubConnectionConfigApplyConfiguration struct { + AppID *string `json:"appID,omitempty"` + InstallationID *string `json:"installationID,omitempty"` +} + +// GitHubConnectionConfigApplyConfiguration constructs a declarative configuration of the GitHubConnectionConfig type for use with +// apply. +func GitHubConnectionConfig() *GitHubConnectionConfigApplyConfiguration { + return &GitHubConnectionConfigApplyConfiguration{} +} + +// WithAppID sets the AppID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AppID field is set to the value of the last call. +func (b *GitHubConnectionConfigApplyConfiguration) WithAppID(value string) *GitHubConnectionConfigApplyConfiguration { + b.AppID = &value + return b +} + +// WithInstallationID sets the InstallationID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the InstallationID field is set to the value of the last call. +func (b *GitHubConnectionConfigApplyConfiguration) WithInstallationID(value string) *GitHubConnectionConfigApplyConfiguration { + b.InstallationID = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/gitlabconnectionconfig.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/gitlabconnectionconfig.go new file mode 100644 index 00000000000..0238ae3c226 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/gitlabconnectionconfig.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// GitlabConnectionConfigApplyConfiguration represents a declarative configuration of the GitlabConnectionConfig type for use +// with apply. +type GitlabConnectionConfigApplyConfiguration struct { + ClientID *string `json:"clientID,omitempty"` +} + +// GitlabConnectionConfigApplyConfiguration constructs a declarative configuration of the GitlabConnectionConfig type for use with +// apply. +func GitlabConnectionConfig() *GitlabConnectionConfigApplyConfiguration { + return &GitlabConnectionConfigApplyConfiguration{} +} + +// WithClientID sets the ClientID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientID field is set to the value of the last call. +func (b *GitlabConnectionConfigApplyConfiguration) WithClientID(value string) *GitlabConnectionConfigApplyConfiguration { + b.ClientID = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/utils.go b/apps/provisioning/pkg/generated/applyconfiguration/utils.go index 400f2b7c083..52c3faee58c 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/utils.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/utils.go @@ -18,14 +18,28 @@ import ( func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { // Group=provisioning.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithKind("BitbucketConnectionConfig"): + return &provisioningv0alpha1.BitbucketConnectionConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("BitbucketRepositoryConfig"): return &provisioningv0alpha1.BitbucketRepositoryConfigApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("Connection"): + return &provisioningv0alpha1.ConnectionApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("ConnectionSecure"): + return &provisioningv0alpha1.ConnectionSecureApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("ConnectionSpec"): + return &provisioningv0alpha1.ConnectionSpecApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("ConnectionStatus"): + return &provisioningv0alpha1.ConnectionStatusApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("DeleteJobOptions"): return &provisioningv0alpha1.DeleteJobOptionsApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ExportJobOptions"): return &provisioningv0alpha1.ExportJobOptionsApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("GitHubConnectionConfig"): + return &provisioningv0alpha1.GitHubConnectionConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("GitHubRepositoryConfig"): return &provisioningv0alpha1.GitHubRepositoryConfigApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("GitlabConnectionConfig"): + return &provisioningv0alpha1.GitlabConnectionConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("GitLabRepositoryConfig"): return &provisioningv0alpha1.GitLabRepositoryConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("GitRepositoryConfig"): diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/connection.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/connection.go new file mode 100644 index 00000000000..fe585e5c0bb --- /dev/null +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/connection.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + context "context" + + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + applyconfigurationprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1" + scheme "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// ConnectionsGetter has a method to return a ConnectionInterface. +// A group's client should implement this interface. +type ConnectionsGetter interface { + Connections(namespace string) ConnectionInterface +} + +// ConnectionInterface has methods to work with Connection resources. +type ConnectionInterface interface { + Create(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.CreateOptions) (*provisioningv0alpha1.Connection, error) + Update(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.UpdateOptions) (*provisioningv0alpha1.Connection, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, connection *provisioningv0alpha1.Connection, opts v1.UpdateOptions) (*provisioningv0alpha1.Connection, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*provisioningv0alpha1.Connection, error) + List(ctx context.Context, opts v1.ListOptions) (*provisioningv0alpha1.ConnectionList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *provisioningv0alpha1.Connection, err error) + Apply(ctx context.Context, connection *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.Connection, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, connection *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.Connection, err error) + ConnectionExpansion +} + +// connections implements ConnectionInterface +type connections struct { + *gentype.ClientWithListAndApply[*provisioningv0alpha1.Connection, *provisioningv0alpha1.ConnectionList, *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration] +} + +// newConnections returns a Connections +func newConnections(c *ProvisioningV0alpha1Client, namespace string) *connections { + return &connections{ + gentype.NewClientWithListAndApply[*provisioningv0alpha1.Connection, *provisioningv0alpha1.ConnectionList, *applyconfigurationprovisioningv0alpha1.ConnectionApplyConfiguration]( + "connections", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *provisioningv0alpha1.Connection { return &provisioningv0alpha1.Connection{} }, + func() *provisioningv0alpha1.ConnectionList { return &provisioningv0alpha1.ConnectionList{} }, + ), + } +} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_connection.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_connection.go new file mode 100644 index 00000000000..2059d044172 --- /dev/null +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_connection.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1" + typedprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeConnections implements ConnectionInterface +type fakeConnections struct { + *gentype.FakeClientWithListAndApply[*v0alpha1.Connection, *v0alpha1.ConnectionList, *provisioningv0alpha1.ConnectionApplyConfiguration] + Fake *FakeProvisioningV0alpha1 +} + +func newFakeConnections(fake *FakeProvisioningV0alpha1, namespace string) typedprovisioningv0alpha1.ConnectionInterface { + return &fakeConnections{ + gentype.NewFakeClientWithListAndApply[*v0alpha1.Connection, *v0alpha1.ConnectionList, *provisioningv0alpha1.ConnectionApplyConfiguration]( + fake.Fake, + namespace, + v0alpha1.SchemeGroupVersion.WithResource("connections"), + v0alpha1.SchemeGroupVersion.WithKind("Connection"), + func() *v0alpha1.Connection { return &v0alpha1.Connection{} }, + func() *v0alpha1.ConnectionList { return &v0alpha1.ConnectionList{} }, + func(dst, src *v0alpha1.ConnectionList) { dst.ListMeta = src.ListMeta }, + func(list *v0alpha1.ConnectionList) []*v0alpha1.Connection { return gentype.ToPointerSlice(list.Items) }, + func(list *v0alpha1.ConnectionList, items []*v0alpha1.Connection) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go index d6fe94156be..2f1422cadc1 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go @@ -14,6 +14,10 @@ type FakeProvisioningV0alpha1 struct { *testing.Fake } +func (c *FakeProvisioningV0alpha1) Connections(namespace string) v0alpha1.ConnectionInterface { + return newFakeConnections(c, namespace) +} + func (c *FakeProvisioningV0alpha1) HistoricJobs(namespace string) v0alpha1.HistoricJobInterface { return newFakeHistoricJobs(c, namespace) } diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go index 21b7a18b414..5220accc813 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go @@ -4,6 +4,8 @@ package v0alpha1 +type ConnectionExpansion interface{} + type HistoricJobExpansion interface{} type JobExpansion interface{} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go index 401bc533cfe..03a1e2e0ceb 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go @@ -14,6 +14,7 @@ import ( type ProvisioningV0alpha1Interface interface { RESTClient() rest.Interface + ConnectionsGetter HistoricJobsGetter JobsGetter RepositoriesGetter @@ -24,6 +25,10 @@ type ProvisioningV0alpha1Client struct { restClient rest.Interface } +func (c *ProvisioningV0alpha1Client) Connections(namespace string) ConnectionInterface { + return newConnections(c, namespace) +} + func (c *ProvisioningV0alpha1Client) HistoricJobs(namespace string) HistoricJobInterface { return newHistoricJobs(c, namespace) } diff --git a/apps/provisioning/pkg/generated/informers/externalversions/generic.go b/apps/provisioning/pkg/generated/informers/externalversions/generic.go index 4a62aab044a..8429b75d959 100644 --- a/apps/provisioning/pkg/generated/informers/externalversions/generic.go +++ b/apps/provisioning/pkg/generated/informers/externalversions/generic.go @@ -39,6 +39,8 @@ func (f *genericInformer) Lister() cache.GenericLister { func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { // Group=provisioning.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithResource("connections"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().Connections().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("historicjobs"): return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().HistoricJobs().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("jobs"): diff --git a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/connection.go b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/connection.go new file mode 100644 index 00000000000..d8fa071219f --- /dev/null +++ b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/connection.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + context "context" + time "time" + + apisprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + versioned "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/internalinterfaces" + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// ConnectionInformer provides access to a shared informer and lister for +// Connections. +type ConnectionInformer interface { + Informer() cache.SharedIndexInformer + Lister() provisioningv0alpha1.ConnectionLister +} + +type connectionInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewConnectionInformer constructs a new informer for Connection type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewConnectionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredConnectionInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredConnectionInformer constructs a new informer for Connection type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredConnectionInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().Connections(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().Connections(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().Connections(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().Connections(namespace).Watch(ctx, options) + }, + }, + &apisprovisioningv0alpha1.Connection{}, + resyncPeriod, + indexers, + ) +} + +func (f *connectionInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredConnectionInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *connectionInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apisprovisioningv0alpha1.Connection{}, f.defaultInformer) +} + +func (f *connectionInformer) Lister() provisioningv0alpha1.ConnectionLister { + return provisioningv0alpha1.NewConnectionLister(f.Informer().GetIndexer()) +} diff --git a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go index cbe2fcefaf3..1a1908ab5f3 100644 --- a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go +++ b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go @@ -10,6 +10,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // Connections returns a ConnectionInformer. + Connections() ConnectionInformer // HistoricJobs returns a HistoricJobInformer. HistoricJobs() HistoricJobInformer // Jobs returns a JobInformer. @@ -29,6 +31,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// Connections returns a ConnectionInformer. +func (v *version) Connections() ConnectionInformer { + return &connectionInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // HistoricJobs returns a HistoricJobInformer. func (v *version) HistoricJobs() HistoricJobInformer { return &historicJobInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/connection.go b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/connection.go new file mode 100644 index 00000000000..a12902c2bea --- /dev/null +++ b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/connection.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// ConnectionLister helps list Connections. +// All objects returned here must be treated as read-only. +type ConnectionLister interface { + // List lists all Connections in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*provisioningv0alpha1.Connection, err error) + // Connections returns an object that can list and get Connections. + Connections(namespace string) ConnectionNamespaceLister + ConnectionListerExpansion +} + +// connectionLister implements the ConnectionLister interface. +type connectionLister struct { + listers.ResourceIndexer[*provisioningv0alpha1.Connection] +} + +// NewConnectionLister returns a new ConnectionLister. +func NewConnectionLister(indexer cache.Indexer) ConnectionLister { + return &connectionLister{listers.New[*provisioningv0alpha1.Connection](indexer, provisioningv0alpha1.Resource("connection"))} +} + +// Connections returns an object that can list and get Connections. +func (s *connectionLister) Connections(namespace string) ConnectionNamespaceLister { + return connectionNamespaceLister{listers.NewNamespaced[*provisioningv0alpha1.Connection](s.ResourceIndexer, namespace)} +} + +// ConnectionNamespaceLister helps list and get Connections. +// All objects returned here must be treated as read-only. +type ConnectionNamespaceLister interface { + // List lists all Connections in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*provisioningv0alpha1.Connection, err error) + // Get retrieves the Connection from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*provisioningv0alpha1.Connection, error) + ConnectionNamespaceListerExpansion +} + +// connectionNamespaceLister implements the ConnectionNamespaceLister +// interface. +type connectionNamespaceLister struct { + listers.ResourceIndexer[*provisioningv0alpha1.Connection] +} diff --git a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go index 7f0649542b3..4d840b6e2db 100644 --- a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go +++ b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go @@ -4,6 +4,14 @@ package v0alpha1 +// ConnectionListerExpansion allows custom methods to be added to +// ConnectionLister. +type ConnectionListerExpansion interface{} + +// ConnectionNamespaceListerExpansion allows custom methods to be added to +// ConnectionNamespaceLister. +type ConnectionNamespaceListerExpansion interface{} + // HistoricJobListerExpansion allows custom methods to be added to // HistoricJobLister. type HistoricJobListerExpansion interface{} diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 3c6e905683c..cb2aa3d08e2 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1,5 +1,5 @@ import { api } from './baseAPI'; -export const addTagTypes = ['API Discovery', 'Job', 'Repository', 'Provisioning'] as const; +export const addTagTypes = ['API Discovery', 'Connection', 'Job', 'Repository', 'Provisioning'] as const; const injectedRtkApi = api .enhanceEndpoints({ addTagTypes, @@ -10,6 +10,156 @@ const injectedRtkApi = api query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), + listConnection: build.query({ + query: (queryArg) => ({ + url: `/connections`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['Connection'], + }), + createConnection: build.mutation({ + query: (queryArg) => ({ + url: `/connections`, + method: 'POST', + body: queryArg.connection, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Connection'], + }), + deletecollectionConnection: build.mutation< + DeletecollectionConnectionApiResponse, + DeletecollectionConnectionApiArg + >({ + query: (queryArg) => ({ + url: `/connections`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['Connection'], + }), + getConnection: build.query({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['Connection'], + }), + replaceConnection: build.mutation({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}`, + method: 'PUT', + body: queryArg.connection, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Connection'], + }), + deleteConnection: build.mutation({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Connection'], + }), + updateConnection: build.mutation({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['Connection'], + }), + getConnectionStatus: build.query({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['Connection'], + }), + replaceConnectionStatus: build.mutation({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.connection, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Connection'], + }), + updateConnectionStatus: build.mutation({ + query: (queryArg) => ({ + url: `/connections/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['Connection'], + }), listJob: build.query({ query: (queryArg) => ({ url: `/jobs`, @@ -411,6 +561,208 @@ const injectedRtkApi = api export { injectedRtkApi as generatedAPI }; export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; +export type ListConnectionApiResponse = /** status 200 OK */ ConnectionList; +export type ListConnectionApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateConnectionApiResponse = /** status 200 OK */ + | Connection + | /** status 201 Created */ Connection + | /** status 202 Accepted */ Connection; +export type CreateConnectionApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + connection: Connection; +}; +export type DeletecollectionConnectionApiResponse = /** status 200 OK */ Status; +export type DeletecollectionConnectionApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetConnectionApiResponse = /** status 200 OK */ Connection; +export type GetConnectionApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceConnectionApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection; +export type ReplaceConnectionApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + connection: Connection; +}; +export type DeleteConnectionApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteConnectionApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateConnectionApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection; +export type UpdateConnectionApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetConnectionStatusApiResponse = /** status 200 OK */ Connection; +export type GetConnectionStatusApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceConnectionStatusApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection; +export type ReplaceConnectionStatusApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + connection: Connection; +}; +export type UpdateConnectionStatusApiResponse = /** status 200 OK */ Connection | /** status 201 Created */ Connection; +export type UpdateConnectionStatusApiArg = { + /** name of the Connection */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; export type ListJobApiResponse = /** status 200 OK */ JobList; export type ListJobApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1053,6 +1405,169 @@ export type ObjectMeta = { Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; +export type InlineSecureValue = + | { + /** Create a secure value -- this is only used for POST/PUT */ + create?: string; + /** Name in the secret service (reference) */ + name: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove?: boolean; + } + | { + /** Create a secure value -- this is only used for POST/PUT */ + create: string; + /** Name in the secret service (reference) */ + name?: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove?: boolean; + } + | { + /** Create a secure value -- this is only used for POST/PUT */ + create?: string; + /** Name in the secret service (reference) */ + name?: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove: boolean; + }; +export type ConnectionSecure = { + /** ClientSecret is the reference to the secret used for other providers authentication, and Github on-behalf-of authentication. This value is stored securely and cannot be read back */ + clientSecret?: InlineSecureValue; + /** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */ + privateKey?: InlineSecureValue; + /** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */ + webhook?: InlineSecureValue; +}; +export type BitbucketConnectionConfig = { + /** App client ID */ + clientID: string; +}; +export type GitHubConnectionConfig = { + /** GitHub App ID */ + appID: string; + /** GitHub App installation ID */ + installationID: string; +}; +export type GitlabConnectionConfig = { + /** App client ID */ + clientID: string; +}; +export type ConnectionSpec = { + /** Bitbucket connection configuration Only applicable when provider is "bitbucket" */ + bitbucket?: BitbucketConnectionConfig; + /** GitHub connection configuration Only applicable when provider is "github" */ + github?: GitHubConnectionConfig; + /** Gitlab connection configuration Only applicable when provider is "gitlab" */ + gitlab?: GitlabConnectionConfig; + /** The connection provider type + + Possible enum values: + - `"bitbucket"` + - `"github"` + - `"gitlab"` */ + type: 'bitbucket' | 'github' | 'gitlab'; + /** The connection URL */ + url?: string; +}; +export type HealthStatus = { + /** When the health was checked last time */ + checked?: number; + /** The type of the error + + Possible enum values: + - `"health"` + - `"hook"` */ + error?: 'health' | 'hook'; + /** When not healthy, requests will not be executed */ + healthy: boolean; + /** Summary messages (can be shown to users) Will only be populated when not healthy */ + message?: string[]; +}; +export type ConnectionStatus = { + /** The connection health status */ + health: HealthStatus; + /** The generation of the spec last time reconciliation ran */ + observedGeneration: number; + /** Connection state + + Possible enum values: + - `"connected"` + - `"disconnected"` */ + state: 'connected' | 'disconnected'; +}; +export type Connection = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ObjectMeta; + secure?: ConnectionSecure; + spec?: ConnectionSpec; + status?: ConnectionStatus; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type ConnectionList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: Connection[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; export type ResourceRef = { /** Group is the group of the resource, such as "dashboard.grafana.app". */ group?: string; @@ -1190,16 +1705,6 @@ export type Job = { spec?: JobSpec; status?: JobStatus; }; -export type ListMeta = { - /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ - continue?: string; - /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ - remainingItemCount?: number; - /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ - resourceVersion?: string; - /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ - selfLink?: string; -}; export type JobList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; @@ -1208,76 +1713,6 @@ export type JobList = { kind?: string; metadata?: ListMeta; }; -export type StatusCause = { - /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. - - Examples: - "name" - the field "name" on the current resource - "items[0].name" - the field "name" on the first array entry in "items" */ - field?: string; - /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ - message?: string; - /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ - reason?: string; -}; -export type StatusDetails = { - /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ - causes?: StatusCause[]; - /** The group attribute of the resource associated with the status StatusReason. */ - group?: string; - /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ - name?: string; - /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ - retryAfterSeconds?: number; - /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ - uid?: string; -}; -export type Status = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - /** Suggested HTTP return code for this status, 0 if not set. */ - code?: number; - /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ - details?: StatusDetails; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** A human-readable description of the status of this operation. */ - message?: string; - /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - metadata?: ListMeta; - /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ - reason?: string; - /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ - status?: string; -}; -export type Patch = object; -export type InlineSecureValue = - | { - /** Create a secure value -- this is only used for POST/PUT */ - create?: string; - /** Name in the secret service (reference) */ - name: string; - /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ - remove?: boolean; - } - | { - /** Create a secure value -- this is only used for POST/PUT */ - create: string; - /** Name in the secret service (reference) */ - name?: string; - /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ - remove?: boolean; - } - | { - /** Create a secure value -- this is only used for POST/PUT */ - create?: string; - /** Name in the secret service (reference) */ - name?: string; - /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ - remove: boolean; - }; export type SecureValues = { /** Token used to connect the configured repository */ token?: InlineSecureValue; @@ -1374,20 +1809,6 @@ export type RepositorySpec = { /** UI driven Workflow that allow changes to the contends of the repository. The order is relevant for defining the precedence of the workflows. When empty, the repository does not support any edits (eg, readonly) */ workflows: ('branch' | 'write')[]; }; -export type HealthStatus = { - /** When the health was checked last time */ - checked?: number; - /** The type of the error - - Possible enum values: - - `"health"` - - `"hook"` */ - error?: 'health' | 'hook'; - /** When not healthy, requests will not be executed */ - healthy: boolean; - /** Summary messages (can be shown to users) Will only be populated when not healthy */ - message?: string[]; -}; export type ResourceCount = { count: number; group: string; @@ -1648,6 +2069,19 @@ export type ResourceStats = { export const { useGetApiResourcesQuery, useLazyGetApiResourcesQuery, + useListConnectionQuery, + useLazyListConnectionQuery, + useCreateConnectionMutation, + useDeletecollectionConnectionMutation, + useGetConnectionQuery, + useLazyGetConnectionQuery, + useReplaceConnectionMutation, + useDeleteConnectionMutation, + useUpdateConnectionMutation, + useGetConnectionStatusQuery, + useLazyGetConnectionStatusQuery, + useReplaceConnectionStatusMutation, + useUpdateConnectionStatusMutation, useListJobQuery, useLazyListJobQuery, useCreateJobMutation, diff --git a/pkg/registry/apis/provisioning/controller/repository_test.go b/pkg/registry/apis/provisioning/controller/repository_test.go index 8358ef9088d..9390d98ec34 100644 --- a/pkg/registry/apis/provisioning/controller/repository_test.go +++ b/pkg/registry/apis/provisioning/controller/repository_test.go @@ -39,6 +39,10 @@ func (m mockProvisioningV0alpha1Interface) Jobs(namespace string) client.JobInte panic("not needed for testing") } +func (m mockProvisioningV0alpha1Interface) Connections(namespace string) client.ConnectionInterface { + panic("not needed for testing") +} + func (m mockProvisioningV0alpha1Interface) Repositories(namespace string) client.RepositoryInterface { if m.repositoriesFunc != nil { return m.repositoriesFunc(namespace) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index cbf50a027f4..b479dc3f8c8 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -31,6 +31,7 @@ import ( dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" @@ -354,6 +355,8 @@ func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attribu return b.authorizeSettings(id) case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName(): return b.authorizeJobs(id) + case provisioning.ConnectionResourceInfo.GetName(): + return b.authorizeConnectionSubresource(a, id) default: return b.authorizeDefault(id) } @@ -437,6 +440,28 @@ func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision, return authorizer.DecisionDeny, "admin role is required", nil } +// authorizeRepositorySubresource handles authorization for connections subresources. +func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { + switch a.GetSubresource() { + case "": + // Doing something with the connection itself. + if id.GetOrgRole().Includes(identity.RoleAdmin) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "admin role is required", nil + case "status": + if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "users cannot update the status of a connection", nil + default: + if id.GetIsGrafanaAdmin() { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil + } +} + // authorizeDefault handles authorization for unmapped resources. func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) { // We haven't bothered with this kind yet. @@ -520,10 +545,19 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.HistoricJobResourceInfo.StoragePath()] = historicJobStore } + connectionsStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.ConnectionResourceInfo, opts.OptsGetter) + if err != nil { + return fmt.Errorf("failed to create connection storage: %w", err) + } + connectionStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, connectionsStore) + storage[provisioning.JobResourceInfo.StoragePath()] = jobStore storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage + storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore + storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage + // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories)) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access) @@ -566,6 +600,11 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis if ok { return nil } + // TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700 + c, ok := obj.(*provisioning.Connection) + if ok { + return connectionvalidation.MutateConnection(c) + } r, ok := obj.(*provisioning.Repository) if !ok { @@ -615,6 +654,11 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } + connection, ok := obj.(*provisioning.Connection) + if ok { + return connectionvalidation.ValidateConnection(connection) + } + // Validate Jobs job, ok := obj.(*provisioning.Job) if ok { diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index ab232f8674c..0eeb7737af2 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -36,6 +36,1126 @@ } } }, + "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections": { + "get": { + "tags": [ + "Connection" + ], + "description": "list or watch objects of kind Connection", + "operationId": "listConnection", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "post": { + "tags": [ + "Connection" + ], + "description": "create a Connection", + "operationId": "createConnection", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "delete": { + "tags": [ + "Connection" + ], + "description": "delete collection of Connection", + "operationId": "deletecollectionConnection", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}": { + "get": { + "tags": [ + "Connection" + ], + "description": "read the specified Connection", + "operationId": "getConnection", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "put": { + "tags": [ + "Connection" + ], + "description": "replace the specified Connection", + "operationId": "replaceConnection", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "delete": { + "tags": [ + "Connection" + ], + "description": "delete a Connection", + "operationId": "deleteConnection", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "patch": { + "tags": [ + "Connection" + ], + "description": "partially update the specified Connection", + "operationId": "updateConnection", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the Connection", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/status": { + "get": { + "tags": [ + "Connection" + ], + "description": "read status of the specified Connection", + "operationId": "getConnectionStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "put": { + "tags": [ + "Connection" + ], + "description": "replace status of the specified Connection", + "operationId": "replaceConnectionStatus", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "patch": { + "tags": [ + "Connection" + ], + "description": "partially update status of the specified Connection", + "operationId": "updateConnectionStatus", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Connection" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the Connection", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/jobs": { "get": { "tags": [ @@ -3198,6 +4318,19 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.BitbucketConnectionConfig": { + "type": "object", + "required": [ + "clientID" + ], + "properties": { + "clientID": { + "description": "App client ID", + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.BitbucketRepositoryConfig": { "type": "object", "required": [ @@ -3223,6 +4356,215 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection": { + "description": "When this code is changed, make sure to update the code generation. As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "secure": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionSecure" + } + ] + }, + "spec": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionSpec" + } + ] + }, + "status": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionStatus" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "Connection", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionList": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Connection" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "ConnectionList", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionSecure": { + "type": "object", + "properties": { + "clientSecret": { + "description": "ClientSecret is the reference to the secret used for other providers authentication, and Github on-behalf-of authentication. This value is stored securely and cannot be read back", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue" + } + ] + }, + "privateKey": { + "description": "PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue" + } + ] + }, + "webhook": { + "description": "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue" + } + ] + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionSpec": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "bitbucket": { + "description": "Bitbucket connection configuration Only applicable when provider is \"bitbucket\"", + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.BitbucketConnectionConfig" + } + ] + }, + "github": { + "description": "GitHub connection configuration Only applicable when provider is \"github\"", + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitHubConnectionConfig" + } + ] + }, + "gitlab": { + "description": "Gitlab connection configuration Only applicable when provider is \"gitlab\"", + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitlabConnectionConfig" + } + ] + }, + "type": { + "description": "The connection provider type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"github\"`\n - `\"gitlab\"`", + "type": "string", + "default": "", + "enum": [ + "bitbucket", + "github", + "gitlab" + ] + }, + "url": { + "description": "The connection URL", + "type": "string" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ConnectionStatus": { + "description": "The status of a Connection. This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually.", + "type": "object", + "required": [ + "observedGeneration", + "state", + "health" + ], + "properties": { + "health": { + "description": "The connection health status", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HealthStatus" + } + ] + }, + "observedGeneration": { + "description": "The generation of the spec last time reconciliation ran", + "type": "integer", + "format": "int64", + "default": 0 + }, + "state": { + "description": "Connection state\n\nPossible enum values:\n - `\"connected\"`\n - `\"disconnected\"`", + "type": "string", + "default": "", + "enum": [ + "connected", + "disconnected" + ] + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.DeleteJobOptions": { "type": "object", "properties": { @@ -3344,6 +4686,25 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitHubConnectionConfig": { + "type": "object", + "required": [ + "appID", + "installationID" + ], + "properties": { + "appID": { + "description": "GitHub App ID", + "type": "string", + "default": "" + }, + "installationID": { + "description": "GitHub App installation ID", + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig": { "type": "object", "required": [ @@ -3415,6 +4776,19 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitlabConnectionConfig": { + "type": "object", + "required": [ + "clientID" + ], + "properties": { + "clientID": { + "description": "App client ID", + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HealthStatus": { "type": "object", "required": [ diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go new file mode 100644 index 00000000000..ea28ac88359 --- /dev/null +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -0,0 +1,413 @@ +package provisioning + +import ( + "context" + "errors" + "testing" + + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + + t.Run("should perform CRUDL requests on connection", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + // CREATE + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.NoError(t, err, "failed to create resource") + + // READ + output, err := helper.Connections.Resource.Get(ctx, "connection", metav1.GetOptions{}) + require.NoError(t, err, "failed to read back resource") + assert.Equal(t, "connection", output.GetName(), "name should be equal") + assert.Equal(t, "default", output.GetNamespace(), "namespace should be equal") + spec := output.Object["spec"].(map[string]any) + assert.Equal(t, "github", spec["type"], "type should be equal") + assert.Equal(t, "https://github.com/settings/installations/454545", spec["url"], "url should be equal") + require.Contains(t, spec, "github") + githubInfo := spec["github"].(map[string]any) + assert.Equal(t, "123456", githubInfo["appID"], "appID should be equal") + assert.Equal(t, "454545", githubInfo["installationID"], "installationID should be equal") + require.Contains(t, output.Object, "secure", "object should contain secure") + assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey") + + // LIST + list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list resource") + assert.Equal(t, 1, len(list.Items), "should have one connection") + assert.Equal(t, "connection", list.Items[0].GetName(), "name should be equal") + + // UPDATE + updatedConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "456789", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{}) + require.NoError(t, err, "failed to update resource") + spec = res.Object["spec"].(map[string]any) + require.Contains(t, spec, "github") + githubInfo = spec["github"].(map[string]any) + assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated") + + // DELETE + require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource") + list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list resources") + assert.Equal(t, 0, len(list.Items), "should have no connections") + }) + + t.Run("viewer can't create or get connection", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + + result := helper.ViewerREST.Post(). + Namespace("default"). + Resource("connections"). + Body(connection). + Do(t.Context()) + + require.NotNil(t, result.Error()) + err := &k8serrors.StatusError{} + require.True(t, errors.As(result.Error(), &err)) + assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason) + assert.Contains(t, err.Status().Message, "User \"viewer\" cannot create resource \"connections\"") + assert.Contains(t, err.Status().Message, "admin role is required") + + result = helper.ViewerREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection"). + Do(t.Context()) + require.NotNil(t, result.Error()) + err = &k8serrors.StatusError{} + require.True(t, errors.As(result.Error(), &err)) + assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason) + assert.Contains(t, err.Status().Message, "User \"viewer\" cannot get resource \"connections\"") + assert.Contains(t, err.Status().Message, "admin role is required") + }) +} + +func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + + t.Run("should fail when type is empty", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "type must be specified") + }) + + t.Run("should fail when type is invalid", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "some-invalid-type", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"") + }) + + t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "github info must be specified for GitHub connection") + }) + + t.Run("should fail when type is github but private key is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection") + }) + + t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + "clientSecret": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection") + }) + + t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "bitbucket", + }, + "secure": map[string]any{ + "clientSecret": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection") + }) + + t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "bitbucket", + "bitbucket": map[string]any{ + "clientID": "123456", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "clientSecret must be specified for Bitbucket connection") + }) + + t.Run("should fail when type is bitbucket but a private key is specified", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "bitbucket", + "bitbucket": map[string]any{ + "clientID": "123456", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + "clientSecret": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "privateKey is forbidden in Bitbucket connection") + }) + + t.Run("should fail when type is gitlab but 'gitlab' field is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "gitlab", + }, + "secure": map[string]any{ + "clientSecret": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection") + }) + + t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "gitlab", + "gitlab": map[string]any{ + "clientID": "123456", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "clientSecret must be specified for Gitlab connection") + }) + + t.Run("should fail when type is gitlab but a private key is specified", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "gitlab", + "gitlab": map[string]any{ + "clientID": "123456", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + "clientSecret": map[string]any{ + "create": "someSecret", + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection") + }) +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 814c29ea11e..5f2ec6a3985 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -53,6 +53,7 @@ type provisioningTestHelper struct { ProvisioningPath string Repositories *apis.K8sResourceClient + Connections *apis.K8sResourceClient Jobs *apis.K8sResourceClient Folders *apis.K8sResourceClient DashboardsV0 *apis.K8sResourceClient @@ -703,6 +704,11 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper Namespace: "default", // actually org1 GVR: provisioning.RepositoryResourceInfo.GroupVersionResource(), }) + connections := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: "default", // actually org1 + GVR: provisioning.ConnectionResourceInfo.GroupVersionResource(), + }) jobs := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 @@ -763,6 +769,7 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper K8sTestHelper: helper, Repositories: repositories, + Connections: connections, AdminREST: adminClient, EditorREST: editorClient, ViewerREST: viewerClient, From e03f7fe8782aae98ce1bc8781c555512d112e6b6 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:11:23 +0100 Subject: [PATCH 21/21] DynamicDashboards: prevent nested repeats based on the same variable (#114953) --- .../SceneGridRowEditableElement.tsx | 2 +- .../row-actions/RowActionsRenderer.tsx | 5 ++--- .../scene/layout-rows/RowItemEditor.tsx | 7 +++--- .../scene/layout-tabs/TabItemEditor.tsx | 7 +++--- .../RepeatRowSelect/RepeatRowSelect.tsx | 22 +++++++++++++------ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx index 3d89bb9a69a..68f07f4ceed 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx @@ -107,7 +107,7 @@ function RowRepeatSelect({ row, dashboard, id }: { row: SceneGridRow; dashboard: <> { if (repeat) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx index 6a6215aff95..4eaaad95cc0 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx @@ -8,7 +8,7 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { getQueryRunnerFor, useDashboard, useDashboardState } from '../../../utils/utils'; +import { getQueryRunnerFor, useDashboardState } from '../../../utils/utils'; import { DashboardGridItem } from '../DashboardGridItem'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; @@ -18,7 +18,6 @@ import { RowOptionsButton } from './RowOptionsButton'; export function RowActionsRenderer({ model }: SceneComponentProps) { const row = model.getParent(); const { title, children } = row.useState(); - const dashboard = useDashboard(model); const { meta, isEditing } = useDashboardState(model); const styles = useStyles2(getStyles); @@ -53,7 +52,7 @@ export function RowActionsRenderer({ model }: SceneComponentProps) { model.onUpdate(title, repeat)} isUsingDashboardDS={isUsingDashboardDS} /> diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index 2df803da9fd..c15551dea04 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -2,7 +2,7 @@ import { useId, useMemo, useRef } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Input, Switch, TextLink, Field } from '@grafana/ui'; +import { Alert, Field, Input, Switch, TextLink } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; @@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor'; import { dashboardEditActions } from '../../edit-pane/shared'; -import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; +import { getQueryRunnerFor } from '../../utils/utils'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils'; @@ -128,7 +128,6 @@ function FillScreenSwitch({ row, id }: { row: RowItem; id?: string }) { function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) { const { layout } = row.useState(); - const dashboard = useDashboard(row); const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => { const runner = getQueryRunnerFor(vizPanel); @@ -143,7 +142,7 @@ function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) { <> row.onChangeRepeat(repeat)} /> diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index ceb04d2fbe4..69f81584a48 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Input, Field, TextLink } from '@grafana/ui'; +import { Alert, Field, Input, TextLink } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; @@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor'; import { dashboardEditActions } from '../../edit-pane/shared'; -import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; +import { getQueryRunnerFor } from '../../utils/utils'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils'; @@ -99,7 +99,6 @@ function TabTitleInput({ tab, isNewElement, id }: { tab: TabItem; isNewElement: function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { const { layout } = tab.useState(); - const dashboard = useDashboard(tab); const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => { const runner = getQueryRunnerFor(vizPanel); @@ -114,7 +113,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { <> tab.onChangeRepeat(repeat)} /> diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index de68a822e3a..94cbecec75c 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { SceneObject, sceneGraph } from '@grafana/scenes'; +import { LocalValueVariable, SceneObject, sceneGraph } from '@grafana/scenes'; import { Combobox, ComboboxOption, Select } from '@grafana/ui'; import { useSelector } from 'app/types/store'; @@ -59,10 +59,18 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) const variables = sceneVars.useState().variables; const variableOptions = useMemo(() => { - const options: ComboboxOption[] = variables.map((item) => ({ - label: item.state.name, - value: item.state.name, - })); + const options: ComboboxOption[] = variables + .filter((item) => { + if (sceneContext.parent) { + // filter out local value variables (which are only set on repeated items) + return !(sceneGraph.lookupVariable(item.state.name, sceneContext.parent) instanceof LocalValueVariable); + } + return true; + }) + .map((item) => ({ + label: item.state.name, + value: item.state.name, + })); options.unshift({ label: t('dashboard.repeat-row-select2.variable-options.label.disable-repeating', 'Disable repeating'), @@ -70,7 +78,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) }); return options; - }, [variables]); + }, [sceneContext, variables]); const onSelectChange = useCallback((value: ComboboxOption | null) => value && onChange(value.value), [onChange]); @@ -79,7 +87,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) return (