From 1be143292661fd94b6d9f1fb0a773f17d34f7132 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 13 Nov 2023 19:51:58 -0800 Subject: [PATCH] K8s: Add subresource to the example apiserver (#78030) --- .github/CODEOWNERS | 2 + pkg/apis/example/v0alpha1/types.go | 27 +++ .../example/v0alpha1/zz_generated.deepcopy.go | 84 +++++++ .../example/v0alpha1/zz_generated.defaults.go | 19 ++ .../example/v0alpha1/zz_generated.openapi..go | 89 ------- .../example/v0alpha1/zz_generated.openapi.go | 220 ++++++++++++++++++ pkg/registry/apis/example/dummy_storage.go | 118 ++++++++++ pkg/registry/apis/example/register.go | 74 +++--- pkg/registry/apis/example/storage.go | 30 ++- pkg/registry/apis/example/subresource.go | 54 +++++ pkg/services/grafana-apiserver/common.go | 8 +- .../grafana-apiserver/request_handler.go | 22 -- pkg/tests/apis/example/example_test.go | 167 +++++++++++++ pkg/tests/apis/helper.go | 56 ++++- pkg/tests/apis/playlist/playlist_test.go | 40 +++- public/app/core/utils/colors.ts | 2 +- 16 files changed, 844 insertions(+), 168 deletions(-) create mode 100644 pkg/apis/example/v0alpha1/zz_generated.defaults.go delete mode 100644 pkg/apis/example/v0alpha1/zz_generated.openapi..go create mode 100644 pkg/apis/example/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/registry/apis/example/dummy_storage.go create mode 100644 pkg/registry/apis/example/subresource.go create mode 100644 pkg/tests/apis/example/example_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 188d783eccb..1858d381855 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -138,6 +138,7 @@ /pkg/services/validations/ @grafana/backend-platform /pkg/setting/ @grafana/backend-platform /pkg/tests/ @grafana/backend-platform +/pkg/tests/apis/ @grafana/grafana-app-platform-squad /pkg/tests/api/correlations/ @grafana/explore-squad /pkg/tsdb/grafanads/ @grafana/backend-platform /pkg/tsdb/intervalv2/ @grafana/backend-platform @@ -603,6 +604,7 @@ embed.go @grafana/grafana-as-code /pkg/kinds/ @grafana/grafana-as-code /pkg/cuectx/ @grafana/grafana-as-code /pkg/registry/ @grafana/grafana-as-code +/pkg/registry/apis/ @grafana/grafana-app-platform-squad /pkg/codegen/ @grafana/grafana-as-code /pkg/kinds/*/*_gen.go @grafana/grafana-as-code /pkg/registry/corekind/ @grafana/grafana-as-code diff --git a/pkg/apis/example/v0alpha1/types.go b/pkg/apis/example/v0alpha1/types.go index 61ba3e89474..ada5a2c2def 100644 --- a/pkg/apis/example/v0alpha1/types.go +++ b/pkg/apis/example/v0alpha1/types.go @@ -4,10 +4,12 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// Mirrors the info exposed in "github.com/grafana/grafana/pkg/setting" // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type RuntimeInfo struct { metav1.TypeMeta `json:",inline"` + // Unix timestamp when the process started StartupTime int64 `json:"startupTime,omitempty"` BuildVersion string `json:"buildVersion,omitempty"` @@ -18,3 +20,28 @@ type RuntimeInfo struct { IsEnterprise bool `json:"enterprise,omitempty"` Packaging string `json:"packaging,omitempty"` } + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DummyResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec string `json:"spec,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DummyResourceList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []DummyResource `json:"items,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DummySubresource struct { + metav1.TypeMeta `json:",inline"` + + // add subresource info here + Info string `json:"info,omitempty"` +} diff --git a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go index 487bf8de819..40b6a4b7688 100644 --- a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go @@ -11,6 +11,90 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DummyResource) DeepCopyInto(out *DummyResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummyResource. +func (in *DummyResource) DeepCopy() *DummyResource { + if in == nil { + return nil + } + out := new(DummyResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DummyResource) 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 *DummyResourceList) DeepCopyInto(out *DummyResourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DummyResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummyResourceList. +func (in *DummyResourceList) DeepCopy() *DummyResourceList { + if in == nil { + return nil + } + out := new(DummyResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DummyResourceList) 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 *DummySubresource) DeepCopyInto(out *DummySubresource) { + *out = *in + out.TypeMeta = in.TypeMeta + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DummySubresource. +func (in *DummySubresource) DeepCopy() *DummySubresource { + if in == nil { + return nil + } + out := new(DummySubresource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DummySubresource) 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 *RuntimeInfo) DeepCopyInto(out *RuntimeInfo) { *out = *in diff --git a/pkg/apis/example/v0alpha1/zz_generated.defaults.go b/pkg/apis/example/v0alpha1/zz_generated.defaults.go new file mode 100644 index 00000000000..238fc2f4edc --- /dev/null +++ b/pkg/apis/example/v0alpha1/zz_generated.defaults.go @@ -0,0 +1,19 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi..go b/pkg/apis/example/v0alpha1/zz_generated.openapi..go deleted file mode 100644 index dc68decf0ef..00000000000 --- a/pkg/apis/example/v0alpha1/zz_generated.openapi..go +++ /dev/null @@ -1,89 +0,0 @@ -package v0alpha1 - -import ( - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -// NOTE: this must match the golang fully qualified name! -const kindKey = "github.com/grafana/grafana/pkg/apis/example/v0alpha1.RuntimeInfo" - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - kindKey: schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref), - } -} - -func schema_pkg_apis_example_v0alpha1_RuntimeInfo(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: "", - }, - }, - "startupTime": { - SchemaProps: spec.SchemaProps{ - Type: []string{"integer"}, - Format: "int64", - }, - }, - "buildVersion": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildCommit": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "enterpriseBuildCommit": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildBranch": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "buildStamp": { - SchemaProps: spec.SchemaProps{ - Type: []string{"integer"}, - Format: "int64", - }, - }, - "enterprise": { - SchemaProps: spec.SchemaProps{ - Type: []string{"boolean"}, - Format: "", - }, - }, - "packaging": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} diff --git a/pkg/apis/example/v0alpha1/zz_generated.openapi.go b/pkg/apis/example/v0alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..66909a8fb14 --- /dev/null +++ b/pkg/apis/example/v0alpha1/zz_generated.openapi.go @@ -0,0 +1,220 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +// This file was autogenerated by openapi-gen. Do not edit it manually! + +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResource": schema_pkg_apis_example_v0alpha1_DummyResource(ref), + "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResourceList": schema_pkg_apis_example_v0alpha1_DummyResourceList(ref), + "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummySubresource": schema_pkg_apis_example_v0alpha1_DummySubresource(ref), + "github.com/grafana/grafana/pkg/apis/example/v0alpha1.RuntimeInfo": schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref), + } +} + +func schema_pkg_apis_example_v0alpha1_DummyResource(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.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_example_v0alpha1_DummyResourceList(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": { + 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/pkg/apis/example/v0alpha1.DummyResource"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/example/v0alpha1.DummyResource", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_example_v0alpha1_DummySubresource(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: "", + }, + }, + "info": { + SchemaProps: spec.SchemaProps{ + Description: "add subresource info here", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + +func schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Mirrors the info exposed in \"github.com/grafana/grafana/pkg/setting\"", + 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: "", + }, + }, + "startupTime": { + SchemaProps: spec.SchemaProps{ + Description: "Unix timestamp when the process started", + Type: []string{"integer"}, + Format: "int64", + }, + }, + "buildVersion": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildCommit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "enterpriseBuildCommit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildBranch": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildStamp": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, + "enterprise": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "packaging": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/registry/apis/example/dummy_storage.go b/pkg/registry/apis/example/dummy_storage.go new file mode 100644 index 00000000000..0099b744125 --- /dev/null +++ b/pkg/registry/apis/example/dummy_storage.go @@ -0,0 +1,118 @@ +package example + +import ( + "context" + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + + example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" +) + +var ( + _ rest.Storage = (*dummyStorage)(nil) + _ rest.Scoper = (*dummyStorage)(nil) + _ rest.SingularNameProvider = (*dummyStorage)(nil) + _ rest.Getter = (*dummyStorage)(nil) + _ rest.Lister = (*dummyStorage)(nil) +) + +type dummyStorage struct { + store *genericregistry.Store + names []string + creationTimestamp metav1.Time +} + +func newDummyStorage(gv schema.GroupVersion, scheme *runtime.Scheme, names ...string) *dummyStorage { + strategy := grafanaregistry.NewStrategy(scheme) + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &example.DummyResource{} }, // getter not supported + NewListFunc: func() runtime.Object { return &example.DummyResourceList{} }, // both list and get return the same thing + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: gv.WithResource("dummy").GroupResource(), + SingularQualifiedResource: gv.WithResource("dummy").GroupResource(), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + store.TableConvertor = rest.NewDefaultTableConvertor(store.DefaultQualifiedResource) + + return &dummyStorage{ + store: store, + names: names, + creationTimestamp: metav1.Now(), + } +} + +func (s *dummyStorage) New() runtime.Object { + return s.store.New() +} + +func (s *dummyStorage) Destroy() {} + +func (s *dummyStorage) NamespaceScoped() bool { + return true +} + +func (s *dummyStorage) GetSingularName() string { + return "dummy" +} + +func (s *dummyStorage) NewList() runtime.Object { + return s.store.NewListFunc() +} + +func (s *dummyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.store.TableConvertor.ConvertToTable(ctx, object, tableOptions) +} + +func (s *dummyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := grafanarequest.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + idx := slices.Index(s.names, name) + if idx < 0 { + return nil, fmt.Errorf("dummy not found") + } + + return &example.DummyResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: info.Value, + CreationTimestamp: s.creationTimestamp, + ResourceVersion: "1", + }, + Spec: fmt.Sprintf("dummy: %s", name), + }, nil +} + +func (s *dummyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + info, err := grafanarequest.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + res := &example.DummyResourceList{} + for _, name := range s.names { + res.Items = append(res.Items, example.DummyResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: info.Value, + CreationTimestamp: s.creationTimestamp, + ResourceVersion: "1", + }, + Spec: fmt.Sprintf("dummy: %s", name), + }) + } + return res, nil +} diff --git a/pkg/registry/apis/example/register.go b/pkg/registry/apis/example/register.go index 2e7222f7b10..0632b164aec 100644 --- a/pkg/registry/apis/example/register.go +++ b/pkg/registry/apis/example/register.go @@ -33,27 +33,50 @@ var _ grafanaapiserver.APIGroupBuilder = (*TestingAPIBuilder)(nil) // This is used just so wire has something unique to return type TestingAPIBuilder struct { codecs serializer.CodecFactory + gv schema.GroupVersion } func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration grafanaapiserver.APIRegistrar) *TestingAPIBuilder { if !features.IsEnabled(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis } - builder := &TestingAPIBuilder{} + builder := &TestingAPIBuilder{ + gv: schema.GroupVersion{Group: GroupName, Version: VersionID}, + } apiregistration.RegisterAPI(builder) return builder } func (b *TestingAPIBuilder) GetGroupVersion() schema.GroupVersion { - return SchemeGroupVersion + return b.gv +} + +func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { + scheme.AddKnownTypes(gv, + &example.RuntimeInfo{}, + &example.DummyResource{}, + &example.DummyResourceList{}, + &example.DummySubresource{}, + ) } func (b *TestingAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - err := AddToScheme(scheme) - if err != nil { - return err - } - return scheme.SetVersionPriority(SchemeGroupVersion) + addKnownTypes(scheme, b.gv) + + // Link this version to the internal representation. + // This is used for server-side-apply (PATCH), and avoids the error: + // "no kind is registered for the type" + addKnownTypes(scheme, schema.GroupVersion{ + Group: b.gv.Group, + Version: runtime.APIVersionInternal, + }) + + // If multiple versions exist, then register conversions from zz_generated.conversion.go + // if err := playlist.RegisterConversions(scheme); err != nil { + // return err + // } + metav1.AddToGroupVersion(scheme, b.gv) + return scheme.SetVersionPriority(b.gv) } func (b *TestingAPIBuilder) GetAPIGroupInfo( @@ -61,11 +84,14 @@ func (b *TestingAPIBuilder) GetAPIGroupInfo( codecs serializer.CodecFactory, // pointer? optsGetter generic.RESTOptionsGetter, ) (*genericapiserver.APIGroupInfo, error) { - apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs) - storage := map[string]rest.Storage{} - storage["runtime"] = newDeploymentInfoStorage() - apiGroupInfo.VersionedResourcesStorageMap[VersionID] = storage b.codecs = codecs + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs) + + storage := map[string]rest.Storage{} + storage["runtime"] = newDeploymentInfoStorage(b.gv, scheme) + storage["dummy"] = newDummyStorage(b.gv, scheme, "test1", "test2", "test3") + storage["dummy/sub"] = &dummySubresourceREST{} + apiGroupInfo.VersionedResourcesStorageMap[VersionID] = storage return &apiGroupInfo, nil } @@ -170,29 +196,3 @@ func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { }, } } - -// SchemeGroupVersion is group version used to register these objects -var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: VersionID} - -// Resource takes an unqualified resource and returns a Group qualified GroupResource -func Resource(resource string) schema.GroupResource { - return SchemeGroupVersion.WithResource(resource).GroupResource() -} - -var ( - // SchemeBuilder points to a list of functions added to Scheme. - SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) - localSchemeBuilder = &SchemeBuilder - // AddToScheme is a common registration function for mapping packaged scoped group & version keys to a scheme. - AddToScheme = localSchemeBuilder.AddToScheme -) - -// Adds the list of known types to the given scheme. -func addKnownTypes(scheme *runtime.Scheme) error { - scheme.AddKnownTypes(SchemeGroupVersion, - &example.RuntimeInfo{}, - ) - - metav1.AddToGroupVersion(scheme, SchemeGroupVersion) - return scheme.SetVersionPriority(SchemeGroupVersion) -} diff --git a/pkg/registry/apis/example/storage.go b/pkg/registry/apis/example/storage.go index b1abd27ab18..605e3b73db8 100644 --- a/pkg/registry/apis/example/storage.go +++ b/pkg/registry/apis/example/storage.go @@ -7,25 +7,43 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" "github.com/grafana/grafana/pkg/setting" ) var ( + _ rest.Storage = (*staticStorage)(nil) _ rest.Scoper = (*staticStorage)(nil) _ rest.SingularNameProvider = (*staticStorage)(nil) _ rest.Lister = (*staticStorage)(nil) - _ rest.Storage = (*staticStorage)(nil) ) type staticStorage struct { - info example.RuntimeInfo + Store *genericregistry.Store + info example.RuntimeInfo } -func newDeploymentInfoStorage() *staticStorage { +func newDeploymentInfoStorage(gv schema.GroupVersion, scheme *runtime.Scheme) *staticStorage { + strategy := grafanaregistry.NewStrategy(scheme) + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &example.RuntimeInfo{} }, // getter not supported + NewListFunc: func() runtime.Object { return &example.RuntimeInfo{} }, // both list and get return the same thing + PredicateFunc: grafanaregistry.Matcher, + DefaultQualifiedResource: gv.WithResource("runtime").GroupResource(), + SingularQualifiedResource: gv.WithResource("runtime").GroupResource(), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + store.TableConvertor = rest.NewDefaultTableConvertor(store.DefaultQualifiedResource) + return &staticStorage{ + Store: store, info: example.RuntimeInfo{ TypeMeta: metav1.TypeMeta{ APIVersion: APIVersion, @@ -44,7 +62,7 @@ func newDeploymentInfoStorage() *staticStorage { } func (s *staticStorage) New() runtime.Object { - return &example.RuntimeInfo{} + return s.Store.New() } func (s *staticStorage) Destroy() {} @@ -58,11 +76,11 @@ func (s *staticStorage) GetSingularName() string { } func (s *staticStorage) NewList() runtime.Object { - return &example.RuntimeInfo{} + return s.Store.NewListFunc() } func (s *staticStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return rest.NewDefaultTableConvertor(Resource("runtime")).ConvertToTable(ctx, object, tableOptions) + return s.Store.TableConvertor.ConvertToTable(ctx, object, tableOptions) } func (s *staticStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { diff --git a/pkg/registry/apis/example/subresource.go b/pkg/registry/apis/example/subresource.go new file mode 100644 index 00000000000..d7d41186b54 --- /dev/null +++ b/pkg/registry/apis/example/subresource.go @@ -0,0 +1,54 @@ +package example + +import ( + "context" + "fmt" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + example "github.com/grafana/grafana/pkg/apis/example/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" +) + +type dummySubresourceREST struct{} + +var _ = rest.Connecter(&dummySubresourceREST{}) + +func (r *dummySubresourceREST) New() runtime.Object { + return &example.DummySubresource{} +} + +func (r *dummySubresourceREST) Destroy() { +} + +func (r *dummySubresourceREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *dummySubresourceREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *dummySubresourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + // This response object format is negotiated by k8s + dummy := &example.DummySubresource{ + Info: fmt.Sprintf("%s/%s", info.Value, user.Login), + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + responder.Object(http.StatusOK, dummy) + }), nil +} diff --git a/pkg/services/grafana-apiserver/common.go b/pkg/services/grafana-apiserver/common.go index 94aedc0cd7b..97be4be4fdb 100644 --- a/pkg/services/grafana-apiserver/common.go +++ b/pkg/services/grafana-apiserver/common.go @@ -42,16 +42,12 @@ type APIRouteHandler struct { Handler http.HandlerFunc // when Level = resource, the resource will be available in context } -// APIRoutes define the +// APIRoutes define explicit HTTP handlers in an apiserver +// TBD: is this actually necessary -- there may be more k8s native options for this type APIRoutes struct { // Root handlers are registered directly after the apiVersion identifier Root []APIRouteHandler // Namespace handlers are mounted under the namespace Namespace []APIRouteHandler - - // Resource routes behave the same as pod/logs - // it looks like a sub-resource, however the response is backed directly by an http handler - // The current resource can be fetched through context - Resource map[string]APIRouteHandler } diff --git a/pkg/services/grafana-apiserver/request_handler.go b/pkg/services/grafana-apiserver/request_handler.go index e9f04ad4bda..a7dc46482c9 100644 --- a/pkg/services/grafana-apiserver/request_handler.go +++ b/pkg/services/grafana-apiserver/request_handler.go @@ -71,22 +71,6 @@ func getAPIHandler(delegateHandler http.Handler, restConfig *restclient.Config, sub.HandleFunc(route.Path, route.Handler). Methods(methods...) } - - // getter := makeGetter(restConfig) - for resource, route := range routes.Resource { - err = validPath(route.Path) - if err != nil { - return nil, err - } - fmt.Printf("TODO: %s/%v\n", resource, route) - - // get a client for that resource kind - //getter := makeGetter(restConfig) - - useful = true - // sub.HandleFunc(v.Slug, SubresourceHandlerWrapper(v.Handler, getter)). - // Methods(methods...) - } } if !useful { @@ -184,12 +168,6 @@ func getOpenAPIPostProcessor(builders []APIGroupBuilder) func(*spec3.OpenAPI) (* } } - for resource, route := range routes.Resource { - copy.Paths.Paths[prefix+"/namespaces/{namespace}/"+resource+"{name}"+route.Path] = &spec3.Path{ - PathProps: *route.Spec, - } - } - return ©, nil } } diff --git a/pkg/tests/apis/example/example_test.go b/pkg/tests/apis/example/example_test.go new file mode 100644 index 00000000000..97b3b0425b3 --- /dev/null +++ b/pkg/tests/apis/example/example_test.go @@ -0,0 +1,167 @@ +package playlist + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +func TestExampleApp(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, // do not start extra port 6443 + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServer, + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service + }, + }) + + t.Run("Check runtime info resource", func(t *testing.T) { + // Resource is not namespaced! + client := helper.Org1.Admin.Client.Resource(schema.GroupVersionResource{ + Group: "example.grafana.app", + Version: "v0alpha1", + Resource: "runtime", + }) + rsp, err := client.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + v, ok := rsp.Object["startupTime"].(int64) + require.True(t, ok) + require.Greater(t, v, time.Now().Add(-1*time.Hour).UnixMilli()) // should be within the last hour + }) + + t.Run("Check discovery client", func(t *testing.T) { + disco := helper.NewDiscoveryClient() + resources, err := disco.ServerResourcesForGroupVersion("example.grafana.app/v0alpha1") + require.NoError(t, err) + + v1Disco, err := json.MarshalIndent(resources, "", " ") + require.NoError(t, err) + // fmt.Printf("%s", string(v1Disco)) + + require.JSONEq(t, `{ + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": "example.grafana.app/v0alpha1", + "resources": [ + { + "name": "dummy", + "singularName": "dummy", + "namespaced": true, + "kind": "DummyResource", + "verbs": [ + "get", + "list" + ] + }, + { + "name": "dummy/sub", + "singularName": "", + "namespaced": true, + "kind": "DummySubresource", + "verbs": [ + "get" + ] + }, + { + "name": "runtime", + "singularName": "runtime", + "namespaced": false, + "kind": "RuntimeInfo", + "verbs": [ + "list" + ] + } + ] + }`, string(v1Disco)) + + //fmt.Printf("%s", string(v1Disco)) + require.JSONEq(t, `[ + { + "version": "v0alpha1", + "freshness": "Current", + "resources": [ + { + "resource": "dummy", + "responseKind": { + "group": "", + "kind": "DummyResource", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "dummy", + "subresources": [ + { + "responseKind": { + "group": "", + "kind": "DummySubresource", + "version": "" + }, + "subresource": "sub", + "verbs": [ + "get" + ] + } + ], + "verbs": [ + "get", + "list" + ] + }, + { + "resource": "runtime", + "responseKind": { + "group": "", + "kind": "RuntimeInfo", + "version": "" + }, + "scope": "Cluster", + "singularResource": "runtime", + "verbs": [ + "list" + ] + } + ] + } + ]`, helper.GetGroupVersionInfoJSON("example.grafana.app")) + }) + + t.Run("Check dummy with subresource", func(t *testing.T) { + client := helper.Org1.Viewer.Client.Resource(schema.GroupVersionResource{ + Group: "example.grafana.app", + Version: "v0alpha1", + Resource: "dummy", + }).Namespace("default") + rsp, err := client.Get(context.Background(), "test2", metav1.GetOptions{}) + require.NoError(t, err) + + require.Equal(t, "dummy: test2", rsp.Object["spec"]) + require.Equal(t, "DummyResource", rsp.GetObjectKind().GroupVersionKind().Kind) + + // Now a sub-resource + rsp, err = client.Get(context.Background(), "test2", metav1.GetOptions{}, "sub") + require.NoError(t, err) + + raw, err := json.MarshalIndent(rsp, "", " ") + require.NoError(t, err) + //fmt.Printf("%s", string(raw)) + require.JSONEq(t, `{ + "apiVersion": "example.grafana.app/v0alpha1", + "kind": "DummySubresource", + "info": "default/viewer-1" + }`, string(raw)) + }) +} diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 1ea0a38c8fe..a54821fe171 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -19,6 +19,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer/yaml" yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" @@ -397,7 +398,60 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { } } -func (c K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasources.DataSource { +func (c *K8sTestHelper) NewDiscoveryClient() *discovery.DiscoveryClient { + c.t.Helper() + + baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr()) + conf := &rest.Config{ + Host: baseUrl, + Username: c.Org1.Admin.Identity.GetLogin(), + Password: c.Org1.Admin.password, + } + client, err := discovery.NewDiscoveryClientForConfig(conf) + require.NoError(c.t, err) + return client +} + +func (c *K8sTestHelper) GetGroupVersionInfoJSON(group string) string { + c.t.Helper() + + disco := c.NewDiscoveryClient() + req := disco.RESTClient().Get(). + Prefix("apis"). + SetHeader("Accept", "application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json") + + result := req.Do(context.Background()) + require.NoError(c.t, result.Error()) + + type DiscoItem struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Versions []any `json:"versions,omitempty"` + } + type DiscoList struct { + Items []DiscoItem `json:"items"` + } + + raw, err := result.Raw() + require.NoError(c.t, err) + all := &DiscoList{} + err = json.Unmarshal(raw, all) + require.NoError(c.t, err) + + for _, item := range all.Items { + if item.Metadata.Name == group { + v, err := json.MarshalIndent(item.Versions, "", " ") + require.NoError(c.t, err) + return string(v) + } + } + + require.Fail(c.t, "could not find discovery info for: ", group) + return "" +} + +func (c *K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasources.DataSource { c.t.Helper() dataSource, err := c.env.Server.HTTPServer.DataSourcesService.AddDataSource(context.Background(), cmd) diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 19d908eae65..b1ead8b35e5 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -26,13 +26,43 @@ func TestPlaylist(t *testing.T) { } t.Run("default setup", func(t *testing.T) { - doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + h := doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, // do not start extra port 6443 DisableAnonymous: true, EnableFeatureToggles: []string{ featuremgmt.FlagGrafanaAPIServer, }, })) + + // The accepted verbs will change when dual write is enabled + disco := h.GetGroupVersionInfoJSON("playlist.grafana.app") + // fmt.Printf("%s", string(disco)) + require.JSONEq(t, `[ + { + "version": "v0alpha1", + "freshness": "Current", + "resources": [ + { + "resource": "playlists", + "responseKind": { + "group": "", + "kind": "Playlist", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "playlist", + "verbs": [ + "create", + "delete", + "get", + "list", + "patch", + "update" + ] + } + ] + } + ]`, disco) }) t.Run("with k8s api flag", func(t *testing.T) { @@ -47,17 +77,13 @@ func TestPlaylist(t *testing.T) { }) } -func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) { +func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper { gvr := schema.GroupVersionResource{ Group: "playlist.grafana.app", Version: "v0alpha1", Resource: "playlists", } - defer func() { - helper.Shutdown() - }() - t.Run("Check direct List permissions from different org users", func(t *testing.T) { // Check view permissions rsp := helper.List(helper.Org1.Viewer, "default", gvr) @@ -332,6 +358,8 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) { require.NoError(t, err) require.Empty(t, list.Items) }) + + return helper } // typescript style map function diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index 6d73ab9fbd8..da47ce322d0 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -1,5 +1,5 @@ import config from 'app/core/config'; export function getThemeColor(dark: string, light: string): string { - return config.bootData.user.lightTheme ? light : dark; + return config.bootData?.user?.lightTheme ? light : dark; }