Scopes: Add a /find query endpoint (#87457)

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Todd Treece 2024-05-08 13:35:11 -04:00 committed by GitHub
parent 01d28e01d2
commit 6e4d35e1ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 422 additions and 68 deletions

View File

@ -57,6 +57,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
&ScopeDashboardBindingList{},
&ScopeNode{},
&ScopeNodeList{},
&TreeResults{},
)
//metav1.AddToGroupVersion(scheme, gv)
return nil

View File

@ -82,15 +82,37 @@ type ScopeNode struct {
Spec ScopeNodeSpec `json:"spec,omitempty"`
}
// Type of the item.
// +enum
type NodeType string
// Defines values for ItemType.
const (
NodeTypeContainer NodeType = "container"
NodeTypeLeaf NodeType = "leaf"
)
// Type of the item.
// +enum
type LinkType string
// Defines values for ItemType.
const (
LinkTypeScope LinkType = "scope"
)
type ScopeNodeSpec struct {
//+optional
ParentName *string `json:"parentName"`
Title string `json:"title"`
Description string `json:"description"`
IsLeaf bool `json:"isLeaf"`
IsSelectable bool `json:"isSelectable"`
LeafType string `json:"leafType"`
LeafName string `json:"leafName"`
ParentName string `json:"parentName,omitempty"`
NodeType NodeType `json:"nodeType"` // container | leaf
Title string `json:"title"`
Description string `json:"description,omitempty"`
LinkType LinkType `json:"linkType,omitempty"` // scope (later more things)
LinkID string `json:"linkID,omitempty"` // the k8s name
// ?? should this be a slice of links
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@ -100,3 +122,22 @@ type ScopeNodeList struct {
Items []ScopeNode `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type TreeResults struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []TreeItem `json:"items,omitempty"`
}
type TreeItem struct {
NodeID string `json:"nodeId,omitempty"`
NodeType NodeType `json:"nodeType"` // container | leaf
Title string `json:"title"`
Description string `json:"description,omitempty"`
LinkType LinkType `json:"linkType,omitempty"` // scope (later more things)
LinkID string `json:"linkID,omitempty"` // the k8s name
}

View File

@ -168,7 +168,7 @@ func (in *ScopeNode) DeepCopyInto(out *ScopeNode) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Spec = in.Spec
return
}
@ -226,11 +226,6 @@ func (in *ScopeNodeList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNodeSpec) DeepCopyInto(out *ScopeNodeSpec) {
*out = *in
if in.ParentName != nil {
in, out := &in.ParentName, &out.ParentName
*out = new(string)
**out = **in
}
return
}
@ -264,3 +259,50 @@ func (in *ScopeSpec) DeepCopy() *ScopeSpec {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TreeItem) DeepCopyInto(out *TreeItem) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TreeItem.
func (in *TreeItem) DeepCopy() *TreeItem {
if in == nil {
return nil
}
out := new(TreeItem)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TreeResults) DeepCopyInto(out *TreeResults) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]TreeItem, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TreeResults.
func (in *TreeResults) DeepCopy() *TreeResults {
if in == nil {
return nil
}
out := new(TreeResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TreeResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}

View File

@ -26,6 +26,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeNodeList": schema_pkg_apis_scope_v0alpha1_ScopeNodeList(ref),
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeNodeSpec": schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref),
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeSpec": schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref),
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.TreeItem": schema_pkg_apis_scope_v0alpha1_TreeItem(ref),
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.TreeResults": schema_pkg_apis_scope_v0alpha1_TreeResults(ref),
}
}
@ -365,6 +367,15 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref common.ReferenceCallback)
Format: "",
},
},
"nodeType": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"container\"`\n - `\"leaf\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"container", "leaf"},
},
},
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
@ -374,41 +385,27 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref common.ReferenceCallback)
},
"description": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Type: []string{"string"},
Format: "",
},
},
"isLeaf": {
"linkType": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
Description: "Possible enum values:\n - `\"scope\"`",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"scope"},
},
},
"isSelectable": {
"linkID": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"leafType": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"leafName": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Description: "scope (later more things)",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"title", "description", "isLeaf", "isSelectable", "leafType", "leafName"},
Required: []string{"nodeType", "title"},
},
},
}
@ -474,3 +471,106 @@ func schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref common.ReferenceCallback) comm
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.ScopeFilter"},
}
}
func schema_pkg_apis_scope_v0alpha1_TreeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"nodeId": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"nodeType": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"container\"`\n - `\"leaf\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"container", "leaf"},
},
},
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"description": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"linkType": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"scope\"`",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"scope"},
},
},
"linkID": {
SchemaProps: spec.SchemaProps{
Description: "scope (later more things)",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"nodeType", "title"},
},
},
}
}
func schema_pkg_apis_scope_v0alpha1_TreeResults(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/scope/v0alpha1.TreeItem"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/scope/v0alpha1.TreeItem", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}

View File

@ -0,0 +1 @@
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,TreeItem,NodeID

View File

@ -3,6 +3,7 @@ package builder
import (
"fmt"
"net/http"
"regexp"
goruntime "runtime"
"runtime/debug"
"strconv"
@ -25,6 +26,16 @@ import (
"github.com/grafana/grafana/pkg/apiserver/endpoints/filters"
)
var pathRewriters = []filters.PathRewriter{
{
// TODO: this is a temporary hack to make rest.Connecter work with resource level routes
Pattern: regexp.MustCompile(`(/apis/scope.grafana.app/v0alpha1/namespaces/.*/find$)`),
ReplaceFunc: func(matches []string) string {
return matches[1] + "/name" // connector requires a name
},
},
}
func SetupConfig(
scheme *runtime.Scheme,
serverConfig *genericapiserver.RecommendedConfig,
@ -76,6 +87,7 @@ func SetupConfig(
handler := genericapiserver.DefaultBuildHandlerChain(requestHandler, c)
handler = filters.WithAcceptHeader(handler)
handler = filters.WithPathRewriters(handler, pathRewriters)
handler = k8stracing.WithTracing(handler, serverConfig.TracerProvider, "KubernetesAPI")
return handler

View File

@ -0,0 +1,99 @@
package scope
import (
"context"
"fmt"
"net/http"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1"
)
type findREST struct {
scopeNodeStorage *storage
}
var (
_ rest.Storage = (*findREST)(nil)
_ rest.SingularNameProvider = (*findREST)(nil)
_ rest.Connecter = (*findREST)(nil)
_ rest.Scoper = (*findREST)(nil)
_ rest.StorageMetadata = (*findREST)(nil)
)
func (r *findREST) New() runtime.Object {
// This is added as the "ResponseType" regarless what ProducesObject() says :)
return &scope.TreeResults{}
}
func (r *findREST) Destroy() {}
func (r *findREST) NamespaceScoped() bool {
return true
}
func (r *findREST) GetSingularName() string {
return "TreeResult" // Used for the
}
func (r *findREST) ProducesMIMETypes(verb string) []string {
return []string{"application/json"} // and parquet!
}
func (r *findREST) ProducesObject(verb string) interface{} {
return &scope.TreeResults{}
}
func (r *findREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *findREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, "" // true means you can use the trailing path as a variable
}
func (r *findREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
// See: /pkg/apiserver/builder/helper.go#L34
// The name is set with a rewriter hack
if name != "name" {
return nil, errors.NewNotFound(schema.GroupResource{}, name)
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
parent := req.URL.Query().Get("parent")
results := &scope.TreeResults{}
raw, err := r.scopeNodeStorage.List(ctx, &internalversion.ListOptions{
Limit: 1000,
})
if err != nil {
responder.Error(err)
return
}
all, ok := raw.(*scope.ScopeNodeList)
if !ok {
responder.Error(fmt.Errorf("expected ScopeNodeList"))
return
}
for _, item := range all.Items {
if parent != item.Spec.ParentName && parent != "" {
continue // Someday this will have an index in raw storage on parentName
}
results.Items = append(results.Items, scope.TreeItem{
NodeID: item.Name,
NodeType: item.Spec.NodeType,
Title: item.Spec.Title,
Description: item.Spec.Description,
LinkType: item.Spec.LinkType,
LinkID: item.Spec.LinkID,
})
}
responder.Object(200, results)
}), nil
}

View File

@ -12,6 +12,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1"
"github.com/grafana/grafana/pkg/apiserver/builder"
@ -140,6 +141,11 @@ func (b *ScopeAPIBuilder) GetAPIGroupInfo(
}
storage[scopeNodeResourceInfo.StoragePath()] = scopeNodeStorage
// Adds a rest.Connector
// NOTE! the server has a hardcoded rewrite filter that fills in a name
// so the standard k8s plumbing continues to work
storage["find"] = &findREST{scopeNodeStorage: scopeNodeStorage}
apiGroupInfo.VersionedResourcesStorageMap[scope.VERSION] = storage
return &apiGroupInfo, nil
}
@ -152,3 +158,46 @@ func (b *ScopeAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
func (b *ScopeAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
return nil
}
func (b *ScopeAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = "Grafana scopes"
// The root api URL
root := "/apis/" + b.GetGroupVersion().String() + "/"
// Add query parameters to the rest.Connector
sub := oas.Paths.Paths[root+"namespaces/{namespace}/find/{name}"]
if sub != nil && sub.Get != nil {
sub.Parameters = []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "namespace",
In: "path",
Description: "object name and auth scope, such as for teams and projects",
Example: "default",
Required: true,
},
},
}
sub.Get.Description = "Navigate the scopes tree"
sub.Get.Parameters = []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "parent",
In: "query",
Description: "The parent scope node",
},
},
}
delete(oas.Paths.Paths, root+"namespaces/{namespace}/find/{name}")
oas.Paths.Paths[root+"namespaces/{namespace}/find"] = sub
}
// The root API discovery list
sub = oas.Paths.Paths[root]
if sub != nil && sub.Get != nil {
sub.Get.Tags = []string{"API Discovery"} // sorts first in the list
}
return oas, nil
}

View File

@ -10,12 +10,12 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
apistore "k8s.io/apiserver/pkg/storage"
scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/utils"
apistore "k8s.io/apiserver/pkg/storage"
)
var _ grafanarest.Storage = (*storage)(nil)
@ -176,8 +176,8 @@ func SelectableScopeDashboardBindingFields(obj *scope.ScopeDashboardBinding) fie
func SelectableScopeNodeFields(obj *scope.ScopeNode) fields.Set {
parentName := ""
if obj.Spec.ParentName != nil {
parentName = *obj.Spec.ParentName
if obj != nil {
parentName = obj.Spec.ParentName
}
return generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false), fields.Set{

View File

@ -40,44 +40,54 @@ func TestIntegrationScopes(t *testing.T) {
v1Disco, err := json.MarshalIndent(resources, "", " ")
require.NoError(t, err)
//fmt.Printf("%s", string(v1Disco))
require.JSONEq(t, `{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "scope.grafana.app/v0alpha1",
"resources": [
{
"name": "scopedashboardbindings",
"singularName": "scopedashboardbinding",
"namespaced": true,
"kind": "ScopeDashboardBinding",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},{
{
"name": "find",
"singularName": "TreeResult",
"namespaced": true,
"kind": "TreeResults",
"verbs": [
"get"
]
},
{
"name": "scopedashboardbindings",
"singularName": "scopedashboardbinding",
"namespaced": true,
"kind": "ScopeDashboardBinding",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "scopenodes",
"singularName": "scopenode",
"namespaced": true,
"kind": "ScopeNode",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "scopes",
"singularName": "scope",
@ -94,7 +104,6 @@ func TestIntegrationScopes(t *testing.T) {
"watch"
]
}
]
}`, string(v1Disco))
})