diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d8a1e4104bf..1560090a06a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1326,6 +1326,7 @@ embed.go @grafana/grafana-as-code /conf/provisioning/datasources/ @grafana/plugins-platform-backend /conf/provisioning/plugins/ @grafana/plugins-platform-backend /conf/provisioning/sample/ @grafana/grafana-git-ui-sync-team +/conf/apiextensions.ini @grafana/grafana-app-platform-squad # Security /relyance.yaml @grafana/security-team diff --git a/.gitignore b/.gitignore index 5302a698c2f..8f1cbe2c33d 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,10 @@ profile.cov /pkg/operators/enterprise_* /pkg/operators/**/enterprise_* +# Enterprise apiextensions server +pkg/registry/apis/apiextensions/* +!pkg/registry/apis/apiextensions/register.go + debug.test /examples/*/dist /packaging/**/*.rpm diff --git a/conf/apiextensions.ini b/conf/apiextensions.ini new file mode 100644 index 00000000000..8d9706e5a3d --- /dev/null +++ b/conf/apiextensions.ini @@ -0,0 +1,53 @@ +; Run locally unified storage with SQLite to test +; new API registration changes +app_mode = development +target = all + +[log] +level = debug + +[server] +; HTTPS is required for kubectl (but HTTP works for testing with curl) +protocol = https +http_port = 1111 + +[feature_toggles] +; Enable the apiextensions feature +apiExtensions = true +; Enable unified storage globally +unifiedStorage = true +; Enable search indexing for unified storage +unifiedStorageSearch = true +; Enable the grafana-apiserver explicitly +grafanaAPIServer = true +; Enable K8s aggregator for API discovery aggregation +; NOTE: This is an enterprise-only feature that requires TLS certificates +; This will surface the new registered group APIs to the `/apis` endpoint. +kubernetesAggregator = true + +[grafana-apiserver] +; Use unified storage backed by SQL (uses your Grafana database) +storage_type = unified +; Certificates for the Kubernetes aggregator (generated by hack/make-aggregator-pki.sh) +proxy_client_cert_file = data/grafana-aggregator/client.crt +proxy_client_key_file = data/grafana-aggregator/client.key + +; Configure dashboards to use unified storage +[unified_storage.dashboards.dashboard.grafana.app] +dualWriterMode = 5 + +; Configure folders to use unified storage (required for dashboards) +[unified_storage.folders.folder.grafana.app] +dualWriterMode = 5 + +[database] +; SQLite database for testing +type = sqlite3 +path = grafana.db +high_availability = false + +; Will only be used for the MT grafana +; apiextensions service +; [auth.extended_jwt] +; enabled = true +; jwks_url = "http://localhost:6481/jwks" diff --git a/go.work.sum b/go.work.sum index 3462a071ffd..effb8a231b9 100644 --- a/go.work.sum +++ b/go.work.sum @@ -410,6 +410,7 @@ github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+ye github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= +github.com/at-wat/mqtt-go v0.19.4/go.mod h1:AsiWc9kqVOhqq7LzUeWT/AkKUBfx3Sw5cEe8lc06fqA= github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= @@ -856,8 +857,10 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38= @@ -908,6 +911,7 @@ github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagI github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= @@ -936,6 +940,7 @@ github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= @@ -952,6 +957,7 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= @@ -1347,6 +1353,7 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= @@ -1399,6 +1406,7 @@ github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiy github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/schollz/progressbar/v3 v3.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= github.com/segmentio/asm v1.1.4/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4aeb394b81f..83bb886a55d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -479,6 +479,10 @@ export interface FeatureToggles { */ kubernetesAggregatorCapTokenAuth?: boolean; /** + * Enable Kubernetes CustomResourceDefinition (CRD) support with dynamic API registration + */ + apiExtensions?: boolean; + /** * Enable groupBy variable support in scenes dashboards */ groupByVariable?: boolean; diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 4362c048b0a..e77ea848778 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:*", + "apiextensions.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/apiserver/registry/generic/key.go b/pkg/apiserver/registry/generic/key.go index 01ce8eb89ea..d12790df9f9 100644 --- a/pkg/apiserver/registry/generic/key.go +++ b/pkg/apiserver/registry/generic/key.go @@ -131,19 +131,31 @@ func NamespaceKeyFunc(gr schema.GroupResource) func(ctx context.Context, name st } } -// NoNamespaceKeyFunc is the default function for constructing storage paths -// to a resource relative to the given prefix without a namespace. -func NoNamespaceKeyFunc(ctx context.Context, prefix string, gr schema.GroupResource, name string) (string, error) { - if len(name) == 0 { - return "", apierrors.NewBadRequest("Name parameter required.") +// ClusterScopedKeyFunc constructs storage paths for cluster-scoped resources (no namespace). +func ClusterScopedKeyFunc(gr schema.GroupResource) func(ctx context.Context, name string) (string, error) { + return func(ctx context.Context, name string) (string, error) { + if len(name) == 0 { + return "", apierrors.NewBadRequest("Name parameter required.") + } + if msgs := path.IsValidPathSegmentName(name); len(msgs) != 0 { + return "", apierrors.NewBadRequest(fmt.Sprintf("Name parameter invalid: %q: %s", name, strings.Join(msgs, ";"))) + } + key := &Key{ + Group: gr.Group, + Resource: gr.Resource, + Name: name, + } + return key.String(), nil + } +} + +// ClusterScopedKeyRootFunc is used by the generic registry store for cluster-scoped resources. +func ClusterScopedKeyRootFunc(gr schema.GroupResource) func(ctx context.Context) string { + return func(ctx context.Context) string { + key := &Key{ + Group: gr.Group, + Resource: gr.Resource, + } + return key.String() } - if msgs := path.IsValidPathSegmentName(name); len(msgs) != 0 { - return "", apierrors.NewBadRequest(fmt.Sprintf("Name parameter invalid: %q: %s", name, strings.Join(msgs, ";"))) - } - key := &Key{ - Group: gr.Group, - Resource: gr.Resource, - Name: name, - } - return prefix + key.String(), nil } diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go index 98e2f1fe9df..953b5dfd56f 100644 --- a/pkg/apiserver/registry/generic/storage.go +++ b/pkg/apiserver/registry/generic/storage.go @@ -1,6 +1,8 @@ package generic import ( + "context" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic/registry" @@ -12,16 +14,27 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o gv := resourceInfo.GroupVersion() gv.Version = runtime.APIVersionInternal strategy := NewStrategy(scheme, gv) + + gr := resourceInfo.GroupResource() + var keyRootFunc func(ctx context.Context) string + var keyFunc func(ctx context.Context, name string) (string, error) + if resourceInfo.IsClusterScoped() { strategy = strategy.WithClusterScope() + keyRootFunc = ClusterScopedKeyRootFunc(gr) + keyFunc = ClusterScopedKeyFunc(gr) + } else { + keyRootFunc = KeyRootFunc(gr) + keyFunc = NamespaceKeyFunc(gr) } + store := ®istry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), + KeyRootFunc: keyRootFunc, + KeyFunc: keyFunc, PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), + DefaultQualifiedResource: gr, SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: resourceInfo.TableConverter(), CreateStrategy: strategy, diff --git a/pkg/registry/apis/apiextensions/register.go b/pkg/registry/apis/apiextensions/register.go new file mode 100644 index 00000000000..08dff32cb68 --- /dev/null +++ b/pkg/registry/apis/apiextensions/register.go @@ -0,0 +1,228 @@ +package apiextensions + +import ( + "github.com/prometheus/client_golang/prometheus" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver" + apiextensionsopenapi "k8s.io/apiextensions-apiserver/pkg/generated/openapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + genericapiserver "k8s.io/apiserver/pkg/server" + serverstorage "k8s.io/apiserver/pkg/server/storage" + "k8s.io/kube-openapi/pkg/common" + + authlib "github.com/grafana/authlib/types" + genericregistry "k8s.io/apiserver/pkg/registry/generic" + + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" + + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/apistore" + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +// CRDStorageProvider is an interface for creating CRD REST options getters. +// Enterprise provides the real implementation, OSS returns nil. +type CRDStorageProvider interface { + NewCRDRESTOptionsGetter( + delegate *apistore.RESTOptionsGetter, + unifiedClient resource.ResourceClient, + ) genericregistry.RESTOptionsGetter +} + +// OSSCRDStorageProvider is the OSS implementation that returns nil (feature disabled) +type OSSCRDStorageProvider struct{} + +func ProvideOSSCRDStorageProvider() CRDStorageProvider { + return &OSSCRDStorageProvider{} +} + +func (p *OSSCRDStorageProvider) NewCRDRESTOptionsGetter( + delegate *apistore.RESTOptionsGetter, + unifiedClient resource.ResourceClient, +) genericregistry.RESTOptionsGetter { + return nil +} + +var _ builder.APIGroupBuilder = (*Builder)(nil) + +// Builder implements builder.APIGroupBuilder for CustomResourceDefinitions. +// This implementation uses the Kubernetes apiextensions-apiserver for CRD handling, +// adapted to work with Grafana's unified storage backend. +// +// IMPORTANT: This builder only registers the CRD types with the scheme. +// The actual CRD storage and custom resource handling is done by the +// Kubernetes apiextensions-apiserver, which is created separately and +// chained as a delegate server. +type Builder struct { + features featuremgmt.FeatureToggles + accessClient authlib.AccessClient + unifiedClient resource.ResourceClient + apiExtensionsServer *apiextensionsapiserver.CustomResourceDefinitions + storageProvider CRDStorageProvider +} + +// RegisterAPIService registers the apiextensions API group in single-tenant mode +func RegisterAPIService( + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + apiregistration builder.APIRegistrar, + accessClient authlib.AccessClient, + registerer prometheus.Registerer, + unified resource.ResourceClient, + storageProvider CRDStorageProvider, +) (*Builder, error) { + //nolint:staticcheck // not yet migrated to OpenFeature + if !features.IsEnabledGlobally(featuremgmt.FlagApiExtensions) { + return nil, nil + } + + b := &Builder{ + features: features, + accessClient: accessClient, + unifiedClient: unified, + storageProvider: storageProvider, + } + + // Register the builder to install the schema + apiregistration.RegisterAPI(b) + + return b, nil +} + +// GetAuthorizer returns the authorizer for CRD resources +// Breaks locally now for ST, will need to test in MT +// For ST just comment this out to test +// func (b *Builder) GetAuthorizer() authorizer.Authorizer { +// return grafanaauthorizer.NewServiceAuthorizer() +// } + +// NewAPIService creates an Builder for multi-tenant mode +func NewAPIService( + accessClient authlib.AccessClient, + unified resource.ResourceClient, + registerer prometheus.Registerer, + features featuremgmt.FeatureToggles, + storageProvider CRDStorageProvider, +) (*Builder, error) { + return &Builder{ + features: features, + accessClient: accessClient, + unifiedClient: unified, + storageProvider: storageProvider, + }, nil +} + +// GetGroupVersion returns the API group version for apiextensions.k8s.io/v1 +func (b *Builder) GetGroupVersion() schema.GroupVersion { + return apiextensionsv1.SchemeGroupVersion +} + +// InstallSchema installs the CRD types into the scheme +func (b *Builder) InstallSchema(scheme *runtime.Scheme) error { + gv := b.GetGroupVersion() + + // Register the apiextensions types from the K8s apiextensions-apiserver + // This uses the types and scheme from the K8s package + metav1.AddToGroupVersion(scheme, gv) + + // Add the CRD types to the scheme + scheme.AddKnownTypes(gv, + &apiextensionsv1.CustomResourceDefinition{}, + &apiextensionsv1.CustomResourceDefinitionList{}, + ) + + return scheme.SetVersionPriority(gv) +} + +func (b *Builder) AllowedV0Alpha1Resources() []string { + return nil +} + +// UpdateAPIGroupInfo is a no-op for the apiextensions builder. +// The actual CRD storage is created by the apiextensions server, not the builder. +// This is called by the builder framework but we don't need to do anything here +// since we're using the K8s apiextensions-apiserver which creates its own storage. +func (b *Builder) UpdateAPIGroupInfo( + _ *genericapiserver.APIGroupInfo, + _ builder.APIGroupOptions, +) error { + // Don't install any storage here - the apiextensions server handles this + return nil +} + +// GetOpenAPIDefinitions returns the OpenAPI definitions for CRD types +func (b *Builder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return apiextensionsopenapi.GetOpenAPIDefinitions(ref) + } +} + +// CreateAPIExtensionsServer creates the Kubernetes apiextensions-apiserver +// This server handles CRD storage and custom resource (CR) handling. +// It should be used as a delegate for the main Grafana API server. +func (b *Builder) CreateAPIExtensionsServer( + serverConfig genericapiserver.RecommendedConfig, + delegationTarget genericapiserver.DelegationTarget, + restOptsGetter *apistore.RESTOptionsGetter, +) (*apiextensionsapiserver.CustomResourceDefinitions, error) { + if restOptsGetter == nil { + return nil, nil + } + + // Create the CRD REST options getter that uses unified storage + crdRestOptsGetter := b.storageProvider.NewCRDRESTOptionsGetter(restOptsGetter, b.unifiedClient) + if crdRestOptsGetter == nil { + // Enterprise feature not available + return nil, nil + } + + // Create a fresh copy of the config for the apiextensions server + // We need to clear PostStartHooks to avoid conflicts with hooks + // already registered by the main server (e.g., "playlist") + apiExtensionsGenericConfig := serverConfig + apiExtensionsGenericConfig.PostStartHooks = map[string]genericapiserver.PostStartHookConfigEntry{} + + // Set the RESTOptionsGetter on the GenericConfig + // The K8s apiextensions-apiserver uses GenericConfig.RESTOptionsGetter for CRD storage + // and ExtraConfig.CRDRESTOptionsGetter for Custom Resource storage + apiExtensionsGenericConfig.RESTOptionsGetter = crdRestOptsGetter + + // Enable the CRD resources in the API resource config + apiResourceConfig := serverstorage.NewResourceConfig() + apiResourceConfig.EnableVersions(apiextensionsv1.SchemeGroupVersion) + apiExtensionsGenericConfig.MergedResourceConfig = apiResourceConfig + + // Configure the apiextensions server + apiextensionsConfig := &apiextensionsapiserver.Config{ + GenericConfig: &apiExtensionsGenericConfig, + ExtraConfig: apiextensionsapiserver.ExtraConfig{ + // CRDRESTOptionsGetter is used for Custom Resource (CR) storage, not CRD storage + CRDRESTOptionsGetter: crdRestOptsGetter, + MasterCount: 1, + // Webhook conversion is not supported yet + ServiceResolver: nil, + AuthResolverWrapper: nil, + }, + } + + server, err := apiextensionsConfig.Complete().New(delegationTarget) + if err != nil { + return nil, err + } + + b.apiExtensionsServer = server + return server, nil +} + +// GetAPIExtensionsServer returns the apiextensions server (if created) +func (b *Builder) GetAPIExtensionsServer() *apiextensionsapiserver.CustomResourceDefinitions { + return b.apiExtensionsServer +} + +// SetAPIServer is a no-op for compatibility with the builder interface +func (b *Builder) SetAPIServer(server *genericapiserver.GenericAPIServer) { +} diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 7f01a25e500..1a6898a02ca 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -1,6 +1,7 @@ package apiregistry import ( + "github.com/grafana/grafana/pkg/registry/apis/apiextensions" "github.com/grafana/grafana/pkg/registry/apis/collections" dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/datasource" @@ -19,6 +20,7 @@ type Service struct{} // ProvideRegistryServiceSink is an entry point for each service that will force initialization // and give each builder the chance to register itself with the main server func ProvideRegistryServiceSink( + _ *apiextensions.Builder, _ *dashboardinternal.DashboardsAPIBuilder, _ *datasource.DataSourceAPIBuilder, _ *folders.FolderAPIBuilder, diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 740f2a46cef..1bcc57e2ede 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -4,6 +4,7 @@ import ( "github.com/google/wire" "github.com/grafana/grafana/pkg/apiserver/auditing" + "github.com/grafana/grafana/pkg/registry/apis/apiextensions" "github.com/grafana/grafana/pkg/registry/apis/collections" dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/datasource" @@ -63,6 +64,7 @@ var WireSet = wire.NewSet( provisioningExtras, // Each must be added here *and* in the ServiceSink above + apiextensions.RegisterAPIService, dashboardinternal.RegisterAPIService, datasource.RegisterAPIService, folders.RegisterAPIService, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 5abda77524a..33837f01754 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -49,6 +49,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/registry/apis" + "github.com/grafana/grafana/pkg/registry/apis/apiextensions" "github.com/grafana/grafana/pkg/registry/apis/collections" "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" @@ -873,6 +874,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) + crdStorageProvider := apiextensions.ProvideOSSCRDStorageProvider() + apiextensionsBuilder, err := apiextensions.RegisterAPIService(cfg, featureToggles, apiserverService, accessClient, registerer, resourceClient, crdStorageProvider) + if err != nil { + return nil, err + } dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) @@ -926,7 +932,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(apiextensionsBuilder, dashboardsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -1533,6 +1539,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) + crdStorageProvider := apiextensions.ProvideOSSCRDStorageProvider() + apiextensionsBuilder, err := apiextensions.RegisterAPIService(cfg, featureToggles, apiserverService, accessClient, registerer, resourceClient, crdStorageProvider) + if err != nil { + return nil, err + } dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) @@ -1586,7 +1597,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(apiextensionsBuilder, dashboardsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 4d70c3f2f4c..44aefb8d887 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/registry" apisregistry "github.com/grafana/grafana/pkg/registry/apis" + "github.com/grafana/grafana/pkg/registry/apis/apiextensions" "github.com/grafana/grafana/pkg/registry/apis/provisioning/extras" "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" @@ -149,6 +150,7 @@ var wireExtsBasicSet = wire.NewSet( sql.ProvideStorageBackend, builder.ProvideDefaultBuildHandlerChainFuncFromBuilders, aggregatorrunner.ProvideNoopAggregatorConfigurator, + apiextensions.ProvideOSSCRDStorageProvider, apisregistry.WireSetExts, gsmKMSProviders.ProvideOSSKMSProviders, secret.ProvideSecureValueClient, diff --git a/pkg/services/apiserver/aggregatorrunner/noopaggregator.go b/pkg/services/apiserver/aggregatorrunner/noopaggregator.go index a399955f04e..2a97a231509 100644 --- a/pkg/services/apiserver/aggregatorrunner/noopaggregator.go +++ b/pkg/services/apiserver/aggregatorrunner/noopaggregator.go @@ -3,6 +3,7 @@ package aggregatorrunner import ( "context" + apiextensionsinformers "k8s.io/apiextensions-apiserver/pkg/client/informers/externalversions/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" genericapiserver "k8s.io/apiserver/pkg/server" @@ -21,6 +22,10 @@ func (n NoopAggregatorConfigurator) Run(ctx context.Context, transport *options. return nil, nil } +func (n *NoopAggregatorConfigurator) SetCRDInformer(_ apiextensionsinformers.CustomResourceDefinitionInformer) { + // noop +} + func ProvideNoopAggregatorConfigurator() AggregatorRunner { return &NoopAggregatorConfigurator{} } diff --git a/pkg/services/apiserver/aggregatorrunner/runner.go b/pkg/services/apiserver/aggregatorrunner/runner.go index 8cfb04622dc..7d86c4fa06b 100644 --- a/pkg/services/apiserver/aggregatorrunner/runner.go +++ b/pkg/services/apiserver/aggregatorrunner/runner.go @@ -3,6 +3,7 @@ package aggregatorrunner import ( "context" + apiextensionsinformers "k8s.io/apiextensions-apiserver/pkg/client/informers/externalversions/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" genericapiserver "k8s.io/apiserver/pkg/server" @@ -21,4 +22,8 @@ type AggregatorRunner interface { // Run starts the complete apiserver chain, expects it executes any logic inside a goroutine and doesn't block. Returns the running server. Run(ctx context.Context, transport *options.RoundTripperFunc, stoppedCh chan error) (*genericapiserver.GenericAPIServer, error) + + // SetCRDInformer sets the CRD informer for auto-registering APIServices for CRDs. + // This should be called before Configure if CRD API is enabled. + SetCRDInformer(informer apiextensionsinformers.CustomResourceDefinitionInformer) } diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 5d9e37e649e..137fc418275 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -21,6 +21,8 @@ import ( "k8s.io/client-go/tools/clientcmd" "k8s.io/kube-openapi/pkg/common" + apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver" + "github.com/grafana/authlib/types" "github.com/grafana/dskit/services" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" @@ -345,6 +347,7 @@ func (s *service) start(ctx context.Context) error { serverConfig.MaxRequestBodyBytes = MaxRequestBodyBytes var optsregister apistore.StorageOptionsRegister + var restOptsGetter *apistore.RESTOptionsGetter if o.StorageOptions.StorageType == grafanaapiserveroptions.StorageTypeEtcd { if err := o.RecommendedOptions.Etcd.Validate(); len(err) > 0 { @@ -354,9 +357,9 @@ func (s *service) start(ctx context.Context) error { return err } } else { - getter := apistore.NewRESTOptionsGetterForClient(s.unified, s.secrets, o.RecommendedOptions.Etcd.StorageConfig, s.restConfigProvider) - optsregister = getter.RegisterOptions - serverConfig.RESTOptionsGetter = getter + restOptsGetter = apistore.NewRESTOptionsGetterForClient(s.unified, s.secrets, o.RecommendedOptions.Etcd.StorageConfig, s.restConfigProvider) + optsregister = restOptsGetter.RegisterOptions + serverConfig.RESTOptionsGetter = restOptsGetter } defGetters := []common.GetOpenAPIDefinitions{ @@ -397,8 +400,35 @@ func (s *service) start(ctx context.Context) error { return fmt.Errorf("failed to register post start hooks for app installers: %w", err) } - // Create the server - server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler)) + // Determine the delegate for the main server + var delegationTarget = genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler) + var apiExtensionsServer *apiextensionsapiserver.CustomResourceDefinitions + + //nolint:staticcheck // not yet migrated to OpenFeature + apiExtensionsEnabled := s.features.IsEnabledGlobally(featuremgmt.FlagApiExtensions) + + if apiExtensionsEnabled && restOptsGetter != nil { + // Create the K8s apiextensions-apiserver for CRD/CR handling + // This server handles: + // - CRD storage (create, get, list, update, delete CRDs) + // - Custom resource handling (dynamically serves CRs based on registered CRDs) + s.log.Info("Creating apiextensions server") + apiExtensionsServer, err = s.createAPIExtensionsServer(builders, serverConfig, delegationTarget, restOptsGetter) + if err != nil { + return fmt.Errorf("failed to create apiextensions server: %w", err) + } + if apiExtensionsServer != nil { + s.log.Info("apiextensions server created successfully, chaining as delegate") + // Chain the apiextensions server as the delegate + // Requests go: grafana-apiserver -> apiextensions-apiserver -> notFoundHandler + delegationTarget = apiExtensionsServer.GenericAPIServer + } else { + s.log.Warn("apiextensions server was not created (returned nil)") + } + } + + // Create the main Grafana API server + server, err := serverConfig.Complete().New("grafana-apiserver", delegationTarget) if err != nil { return err } @@ -454,6 +484,11 @@ func (s *service) start(ctx context.Context) error { isDataplaneAggregatorEnabled := s.features.IsEnabledGlobally(featuremgmt.FlagDataplaneAggregator) if isKubernetesAggregatorEnabled { + // Pass CRD informer to aggregator if apiextensions is enabled (for auto-registering APIServices for CRDs) + if apiExtensionsServer != nil && apiExtensionsServer.Informers != nil { + s.log.Info("Starting CRD informer for apiextensions service") + s.aggregatorRunner.SetCRDInformer(apiExtensionsServer.Informers.Apiextensions().V1().CustomResourceDefinitions()) + } aggregatorServer, err := s.aggregatorRunner.Configure(s.options, serverConfig, delegate, s.scheme, builders) if err != nil { return err @@ -658,3 +693,30 @@ func useNamespaceFromPath(path string, user *user.SignedInUser) { } } } + +// createAPIExtensionsServer creates the Kubernetes apiextensions-apiserver for CRD/CR handling. +// This server is chained as a delegate, handling CRD storage and dynamic custom resource serving. +func (s *service) createAPIExtensionsServer( + builders []builder.APIGroupBuilder, + serverConfig *genericapiserver.RecommendedConfig, + delegationTarget genericapiserver.DelegationTarget, + restOptsGetter *apistore.RESTOptionsGetter, +) (*apiextensionsapiserver.CustomResourceDefinitions, error) { + // Find the apiextensions Builder + type apiExtensionsCreator interface { + CreateAPIExtensionsServer( + serverConfig genericapiserver.RecommendedConfig, + delegationTarget genericapiserver.DelegationTarget, + restOptsGetter *apistore.RESTOptionsGetter, + ) (*apiextensionsapiserver.CustomResourceDefinitions, error) + } + + for _, b := range builders { + if creator, ok := b.(apiExtensionsCreator); ok { + return creator.CreateAPIExtensionsServer(*serverConfig, delegationTarget, restOptsGetter) + } + } + + // No apiextensions builder found + return nil, nil +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c1b318c97b4..a332bf93c1a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -783,6 +783,13 @@ var ( Owner: grafanaAppPlatformSquad, RequiresRestart: true, }, + { + Name: "apiExtensions", + Description: "Enable Kubernetes CustomResourceDefinition (CRD) support with dynamic API registration", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + RequiresRestart: true, + }, { Name: "groupByVariable", Description: "Enable groupBy variable support in scenes dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f05b0d41da3..a17286bb7e9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -108,6 +108,7 @@ sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,fa sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesAggregatorCapTokenAuth,experimental,@grafana/grafana-app-platform-squad,false,true,false +apiExtensions,experimental,@grafana/grafana-app-platform-squad,false,true,false groupByVariable,experimental,@grafana/dashboards-squad,false,false,false scopeFilters,experimental,@grafana/dashboards-squad,false,false,false oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6e106fd9950..41d6a0feadc 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -319,6 +319,10 @@ const ( // Enable CAP token based authentication in grafana's embedded kube-aggregator FlagKubernetesAggregatorCapTokenAuth = "kubernetesAggregatorCapTokenAuth" + // FlagApiExtensions + // Enable Kubernetes CustomResourceDefinition (CRD) support with dynamic API registration + FlagApiExtensions = "apiExtensions" + // FlagGroupByVariable // Enable groupBy variable support in scenes dashboards FlagGroupByVariable = "groupByVariable" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 561ffd562ca..d4e454832ad 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -619,6 +619,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "apiExtensions", + "resourceVersion": "1764159104213", + "creationTimestamp": "2025-11-26T12:11:44Z" + }, + "spec": { + "description": "Enable Kubernetes CustomResourceDefinition (CRD) support with dynamic API registration", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "appPlatformGrpcClientAuth", diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index c0ddd9e6613..48e59599976 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -177,6 +177,7 @@ func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req return true }, claims.NoopZookie{}, nil } + if !claims.NamespaceMatches(id.GetNamespace(), req.Namespace) { span.SetAttributes(attribute.Bool("allowed", false)) span.SetStatus(codes.Error, "Namespace mismatch")