mirror of https://github.com/grafana/grafana
K8s/Folders: Add folders api service (with legacy storage) (#79413)
parent
360de108ec
commit
67bbdd7c05
@ -1,25 +0,0 @@ |
||||
package kind |
||||
|
||||
name: "Folder" |
||||
maturity: "merged" |
||||
description: "A folder is a collection of resources that are grouped together and can share permissions." |
||||
|
||||
lineage: schemas: [{ |
||||
version: [0, 0] |
||||
schema: { |
||||
spec: { |
||||
// Unique folder id. (will be k8s name) |
||||
uid: string |
||||
|
||||
// Folder title |
||||
title: string |
||||
|
||||
// Description of the folder. |
||||
description?: string |
||||
} @cuetsy(kind="interface") |
||||
// |
||||
// TODO: |
||||
// common metadata will soon support setting the parent folder in the metadata |
||||
// |
||||
} |
||||
}] |
||||
@ -1,28 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// TSResourceJenny
|
||||
// LatestMajorsOrXJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
/** |
||||
* TODO: |
||||
* common metadata will soon support setting the parent folder in the metadata |
||||
*/ |
||||
export interface Folder { |
||||
/** |
||||
* Description of the folder. |
||||
*/ |
||||
description?: string; |
||||
/** |
||||
* Folder title |
||||
*/ |
||||
title: string; |
||||
/** |
||||
* Unique folder id. (will be k8s name) |
||||
*/ |
||||
uid: string; |
||||
} |
||||
@ -0,0 +1,5 @@ |
||||
// +k8s:deepcopy-gen=package
|
||||
// +k8s:openapi-gen=true
|
||||
// +groupName=folders.grafana.app
|
||||
|
||||
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/folders/v0alpha1"
|
||||
@ -0,0 +1,60 @@ |
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis" |
||||
) |
||||
|
||||
const ( |
||||
GROUP = "folders.grafana.app" |
||||
VERSION = "v0alpha1" |
||||
RESOURCE = "folders" |
||||
APIVERSION = GROUP + "/" + VERSION |
||||
) |
||||
|
||||
var FolderResourceInfo = apis.NewResourceInfo(GROUP, VERSION, |
||||
RESOURCE, "folder", "Folder", |
||||
func() runtime.Object { return &Folder{} }, |
||||
func() runtime.Object { return &FolderList{} }, |
||||
) |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type Folder struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
// TODO, structure so the name is not in spec
|
||||
Spec Spec `json:"spec,omitempty"` |
||||
} |
||||
|
||||
type Spec struct { |
||||
// Describe the feature toggle
|
||||
Title string `json:"title"` |
||||
|
||||
// Describe the feature toggle
|
||||
Description string `json:"description,omitempty"` |
||||
} |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FolderList struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
// +optional
|
||||
metav1.ListMeta `json:"metadata,omitempty"` |
||||
|
||||
Items []Folder `json:"items,omitempty"` |
||||
} |
||||
|
||||
// FolderInfo returns a list of folder indentifiers (parents or children)
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FolderInfo struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
|
||||
Items []FolderItem `json:"items"` |
||||
} |
||||
|
||||
type FolderItem struct { |
||||
Name string `json:"name"` |
||||
Title string `json:"title"` |
||||
} |
||||
@ -0,0 +1,134 @@ |
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by deepcopy-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1 |
||||
|
||||
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 *Folder) DeepCopyInto(out *Folder) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
out.Spec = in.Spec |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Folder.
|
||||
func (in *Folder) DeepCopy() *Folder { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(Folder) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Folder) 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 *FolderInfo) DeepCopyInto(out *FolderInfo) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
if in.Items != nil { |
||||
in, out := &in.Items, &out.Items |
||||
*out = make([]FolderItem, len(*in)) |
||||
copy(*out, *in) |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderInfo.
|
||||
func (in *FolderInfo) DeepCopy() *FolderInfo { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FolderInfo) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FolderInfo) 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 *FolderItem) DeepCopyInto(out *FolderItem) { |
||||
*out = *in |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderItem.
|
||||
func (in *FolderItem) DeepCopy() *FolderItem { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FolderItem) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FolderList) DeepCopyInto(out *FolderList) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ListMeta.DeepCopyInto(&out.ListMeta) |
||||
if in.Items != nil { |
||||
in, out := &in.Items, &out.Items |
||||
*out = make([]Folder, len(*in)) |
||||
for i := range *in { |
||||
(*in)[i].DeepCopyInto(&(*out)[i]) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderList.
|
||||
func (in *FolderList) DeepCopy() *FolderList { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(FolderList) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FolderList) 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 *Spec) DeepCopyInto(out *Spec) { |
||||
*out = *in |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Spec.
|
||||
func (in *Spec) DeepCopy() *Spec { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(Spec) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
@ -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 |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// GoTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package folder |
||||
|
||||
import ( |
||||
"github.com/grafana/grafana/pkg/kinds" |
||||
) |
||||
|
||||
// Resource is the kubernetes style representation of Folder. (TODO be better)
|
||||
type K8sResource = kinds.GrafanaResource[Spec, Status] |
||||
|
||||
// NewResource creates a new instance of the resource with a given name (UID)
|
||||
func NewK8sResource(name string, s *Spec) K8sResource { |
||||
return K8sResource{ |
||||
Kind: "Folder", |
||||
APIVersion: "v0-0-alpha", |
||||
Metadata: kinds.GrafanaResourceMetadata{ |
||||
Name: name, |
||||
Annotations: make(map[string]string), |
||||
Labels: make(map[string]string), |
||||
}, |
||||
Spec: s, |
||||
} |
||||
} |
||||
|
||||
// Resource is the wire representation of Folder.
|
||||
// It currently will soon be merged into the k8s flavor (TODO be better)
|
||||
type Resource struct { |
||||
Metadata Metadata `json:"metadata"` |
||||
Spec Spec `json:"spec"` |
||||
Status Status `json:"status"` |
||||
} |
||||
@ -1,79 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// CoreKindJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package folder |
||||
|
||||
import ( |
||||
"github.com/grafana/kindsys" |
||||
"github.com/grafana/thema" |
||||
"github.com/grafana/thema/vmux" |
||||
|
||||
"github.com/grafana/grafana/pkg/cuectx" |
||||
) |
||||
|
||||
// rootrel is the relative path from the grafana repository root to the
|
||||
// directory containing the .cue files in which this kind is defined. Necessary
|
||||
// for runtime errors related to the definition and/or lineage to provide
|
||||
// a real path to the correct .cue file.
|
||||
const rootrel string = "kinds/folder" |
||||
|
||||
// TODO standard generated docs
|
||||
type Kind struct { |
||||
kindsys.Core |
||||
lin thema.ConvergentLineage[*Resource] |
||||
jcodec vmux.Codec |
||||
valmux vmux.ValueMux[*Resource] |
||||
} |
||||
|
||||
// type guard - ensure generated Kind type satisfies the kindsys.Core interface
|
||||
var _ kindsys.Core = &Kind{} |
||||
|
||||
// TODO standard generated docs
|
||||
func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { |
||||
def, err := cuectx.LoadCoreKindDef(rootrel, rt.Context(), nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
k := &Kind{} |
||||
k.Core, err = kindsys.BindCore(rt, def, opts...) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
// Get the thema.Schema that the meta says is in the current version (which
|
||||
// codegen ensures is always the latest)
|
||||
cursch := thema.SchemaP(k.Core.Lineage(), def.Properties.CurrentVersion) |
||||
tsch, err := thema.BindType(cursch, &Resource{}) |
||||
if err != nil { |
||||
// Should be unreachable, modulo bugs in the Thema->Go code generator
|
||||
return nil, err |
||||
} |
||||
|
||||
k.jcodec = vmux.NewJSONCodec("folder.json") |
||||
k.lin = tsch.ConvergentLineage() |
||||
k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) |
||||
return k, nil |
||||
} |
||||
|
||||
// ConvergentLineage returns the same [thema.Lineage] as Lineage, but bound (see [thema.BindType])
|
||||
// to the the Folder [Resource] type generated from the current schema, v0.0.
|
||||
func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Resource] { |
||||
return k.lin |
||||
} |
||||
|
||||
// JSONValueMux is a version multiplexer that maps a []byte containing JSON data
|
||||
// at any schematized dashboard version to an instance of Folder [Resource].
|
||||
//
|
||||
// Validation and translation errors emitted from this func will identify the
|
||||
// input bytes as "dashboard.json".
|
||||
//
|
||||
// This is a thin wrapper around Thema's [vmux.ValueMux].
|
||||
func (k *Kind) JSONValueMux(b []byte) (*Resource, thema.TranslationLacunas, error) { |
||||
return k.valmux(b) |
||||
} |
||||
@ -1,42 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// GoResourceTypes
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package folder |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
// Metadata defines model for Metadata.
|
||||
type Metadata struct { |
||||
CreatedBy string `json:"createdBy"` |
||||
CreationTimestamp time.Time `json:"creationTimestamp"` |
||||
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` |
||||
|
||||
// extraFields is reserved for any fields that are pulled from the API server metadata but do not have concrete fields in the CUE metadata
|
||||
ExtraFields map[string]any `json:"extraFields"` |
||||
Finalizers []string `json:"finalizers"` |
||||
Labels map[string]string `json:"labels"` |
||||
ResourceVersion string `json:"resourceVersion"` |
||||
Uid string `json:"uid"` |
||||
UpdateTimestamp time.Time `json:"updateTimestamp"` |
||||
UpdatedBy string `json:"updatedBy"` |
||||
} |
||||
|
||||
// _kubeObjectMetadata is metadata found in a kubernetes object's metadata field.
|
||||
// It is not exhaustive and only includes fields which may be relevant to a kind's implementation,
|
||||
// As it is also intended to be generic enough to function with any API Server.
|
||||
type KubeObjectMetadata struct { |
||||
CreationTimestamp time.Time `json:"creationTimestamp"` |
||||
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` |
||||
Finalizers []string `json:"finalizers"` |
||||
Labels map[string]string `json:"labels"` |
||||
ResourceVersion string `json:"resourceVersion"` |
||||
Uid string `json:"uid"` |
||||
} |
||||
@ -1,23 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// GoResourceTypes
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package folder |
||||
|
||||
// TODO:
|
||||
// common metadata will soon support setting the parent folder in the metadata
|
||||
type Spec struct { |
||||
// Description of the folder.
|
||||
Description *string `json:"description,omitempty"` |
||||
|
||||
// Folder title
|
||||
Title string `json:"title"` |
||||
|
||||
// Unique folder id. (will be k8s name)
|
||||
Uid string `json:"uid"` |
||||
} |
||||
@ -1,74 +0,0 @@ |
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// kinds/gen.go
|
||||
// Using jennies:
|
||||
// GoResourceTypes
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
package folder |
||||
|
||||
// Defines values for OperatorStateState.
|
||||
const ( |
||||
OperatorStateStateFailed OperatorStateState = "failed" |
||||
OperatorStateStateInProgress OperatorStateState = "in_progress" |
||||
OperatorStateStateSuccess OperatorStateState = "success" |
||||
) |
||||
|
||||
// Defines values for StatusOperatorStateState.
|
||||
const ( |
||||
StatusOperatorStateStateFailed StatusOperatorStateState = "failed" |
||||
StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" |
||||
StatusOperatorStateStateSuccess StatusOperatorStateState = "success" |
||||
) |
||||
|
||||
// OperatorState defines model for OperatorState.
|
||||
type OperatorState struct { |
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
DescriptiveState *string `json:"descriptiveState,omitempty"` |
||||
|
||||
// details contains any extra information that is operator-specific
|
||||
Details map[string]any `json:"details,omitempty"` |
||||
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
LastEvaluation string `json:"lastEvaluation"` |
||||
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
State OperatorStateState `json:"state"` |
||||
} |
||||
|
||||
// OperatorStateState state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
type OperatorStateState string |
||||
|
||||
// Status defines model for Status.
|
||||
type Status struct { |
||||
// additionalFields is reserved for future use
|
||||
AdditionalFields map[string]any `json:"additionalFields,omitempty"` |
||||
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` |
||||
} |
||||
|
||||
// StatusOperatorState defines model for status.#OperatorState.
|
||||
type StatusOperatorState struct { |
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
DescriptiveState *string `json:"descriptiveState,omitempty"` |
||||
|
||||
// details contains any extra information that is operator-specific
|
||||
Details map[string]any `json:"details,omitempty"` |
||||
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
LastEvaluation string `json:"lastEvaluation"` |
||||
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
State StatusOperatorStateState `json:"state"` |
||||
} |
||||
|
||||
// StatusOperatorStateState state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
type StatusOperatorStateState string |
||||
@ -0,0 +1,53 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"fmt" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion" |
||||
) |
||||
|
||||
type continueToken struct { |
||||
page int64 |
||||
limit int64 |
||||
} |
||||
|
||||
func readContinueToken(options *internalversion.ListOptions) (*continueToken, error) { |
||||
t := &continueToken{ |
||||
limit: 100, // default page size
|
||||
} |
||||
if options.Continue == "" { |
||||
if options.Limit > 0 { |
||||
t.limit = options.Limit |
||||
} |
||||
} else { |
||||
continueVal, err := base64.StdEncoding.DecodeString(options.Continue) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error decoding continue token") |
||||
} |
||||
parts := strings.Split(string(continueVal), "|") |
||||
if len(parts) != 2 { |
||||
return nil, fmt.Errorf("error decoding continue token (expected two parts)") |
||||
} |
||||
|
||||
t.page, err = strconv.ParseInt(parts[1], 10, 64) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
t.limit, err = strconv.ParseInt(parts[0], 10, 64) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if options.Limit > 0 && options.Limit != t.limit { |
||||
return nil, fmt.Errorf("limit does not match continue token") |
||||
} |
||||
} |
||||
|
||||
return t, nil |
||||
} |
||||
|
||||
func (t *continueToken) GetNextPageToken() string { |
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%d", t.limit, t.page+1))) |
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion" |
||||
) |
||||
|
||||
func TestContinueToken(t *testing.T) { |
||||
token, err := readContinueToken(&internalversion.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, int64(100), token.limit) |
||||
require.Equal(t, int64(0), token.page) |
||||
|
||||
next := token.GetNextPageToken() |
||||
require.Equal(t, "MTAwfDE=", next) |
||||
token, err = readContinueToken(&internalversion.ListOptions{Continue: next}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, int64(100), token.limit) |
||||
require.Equal(t, int64(1), token.page) // <<< +1
|
||||
|
||||
// Error if the limit has changed
|
||||
_, err = readContinueToken(&internalversion.ListOptions{Continue: next, Limit: 50}) |
||||
require.Error(t, err) |
||||
} |
||||
@ -0,0 +1,46 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/kinds" |
||||
"github.com/grafana/grafana/pkg/services/folder" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" |
||||
) |
||||
|
||||
func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { |
||||
meta := kinds.GrafanaResourceMetadata{} |
||||
meta.SetUpdatedTimestampMillis(v.Updated.UnixMilli()) |
||||
if v.ID > 0 { // nolint:staticcheck
|
||||
meta.SetOriginInfo(&kinds.ResourceOriginInfo{ |
||||
Name: "SQL", |
||||
Key: fmt.Sprintf("%d", v.ID), // nolint:staticcheck
|
||||
}) |
||||
} |
||||
if v.CreatedBy > 0 { |
||||
meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) |
||||
} |
||||
if v.UpdatedBy > 0 { |
||||
meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) |
||||
} |
||||
f := &v0alpha1.Folder{ |
||||
TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), |
||||
ObjectMeta: metav1.ObjectMeta{ |
||||
Name: v.UID, |
||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()), |
||||
CreationTimestamp: metav1.NewTime(v.Created), |
||||
Namespace: namespacer(v.OrgID), |
||||
Annotations: meta.Annotations, |
||||
}, |
||||
Spec: v0alpha1.Spec{ |
||||
Title: v.Title, |
||||
Description: v.Description, |
||||
}, |
||||
} |
||||
f.UID = utils.CalculateClusterWideUID(f) |
||||
return f |
||||
} |
||||
@ -0,0 +1,272 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/infra/appcontext" |
||||
"github.com/grafana/grafana/pkg/kinds" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/services/folder" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
"github.com/grafana/grafana/pkg/util" |
||||
) |
||||
|
||||
var ( |
||||
_ rest.Scoper = (*legacyStorage)(nil) |
||||
_ rest.SingularNameProvider = (*legacyStorage)(nil) |
||||
_ rest.Getter = (*legacyStorage)(nil) |
||||
_ rest.Lister = (*legacyStorage)(nil) |
||||
_ rest.Storage = (*legacyStorage)(nil) |
||||
_ rest.Creater = (*legacyStorage)(nil) |
||||
_ rest.Updater = (*legacyStorage)(nil) |
||||
_ rest.GracefulDeleter = (*legacyStorage)(nil) |
||||
) |
||||
|
||||
type legacyStorage struct { |
||||
service folder.Service |
||||
namespacer request.NamespaceMapper |
||||
tableConverter rest.TableConvertor |
||||
} |
||||
|
||||
func (s *legacyStorage) New() runtime.Object { |
||||
return resourceInfo.NewFunc() |
||||
} |
||||
|
||||
func (s *legacyStorage) Destroy() {} |
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool { |
||||
return true // namespace == org
|
||||
} |
||||
|
||||
func (s *legacyStorage) GetSingularName() string { |
||||
return resourceInfo.GetSingularName() |
||||
} |
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object { |
||||
return resourceInfo.NewListFunc() |
||||
} |
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { |
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions) |
||||
} |
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { |
||||
orgId, err := request.OrgIDForList(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
paging, err := readContinueToken(options) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// When nested folders are not enabled, all folders are root folders
|
||||
hits, err := s.service.GetChildren(ctx, &folder.GetChildrenQuery{ |
||||
SignedInUser: user, |
||||
Limit: paging.page, |
||||
OrgID: orgId, |
||||
Page: paging.limit, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
list := &v0alpha1.FolderList{} |
||||
for _, v := range hits { |
||||
list.Items = append(list.Items, *convertToK8sResource(v, s.namespacer)) |
||||
} |
||||
if len(list.Items) >= int(paging.limit) { |
||||
list.Continue = paging.GetNextPageToken() |
||||
} |
||||
return list, nil |
||||
} |
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { |
||||
info, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
dto, err := s.service.Get(ctx, &folder.GetFolderQuery{ |
||||
SignedInUser: user, |
||||
UID: &name, |
||||
OrgID: info.OrgID, |
||||
}) |
||||
if err != nil || dto == nil { |
||||
if errors.Is(err, dashboards.ErrFolderNotFound) || err == nil { |
||||
err = resourceInfo.NewNotFound(name) |
||||
} |
||||
return nil, err |
||||
} |
||||
|
||||
return convertToK8sResource(dto, s.namespacer), nil |
||||
} |
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context, |
||||
obj runtime.Object, |
||||
createValidation rest.ValidateObjectFunc, |
||||
options *metav1.CreateOptions, |
||||
) (runtime.Object, error) { |
||||
info, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
p, ok := obj.(*v0alpha1.Folder) |
||||
if !ok { |
||||
return nil, fmt.Errorf("expected folder?") |
||||
} |
||||
|
||||
// Simplify creating unique folder names with
|
||||
if p.GenerateName != "" && strings.Contains(p.Spec.Title, "${RAND}") { |
||||
rand, _ := util.GetRandomString(10) |
||||
p.Spec.Title = strings.ReplaceAll(p.Spec.Title, "${RAND}", rand) |
||||
} |
||||
|
||||
accessor := kinds.MetaAccessor(p) |
||||
parent := accessor.GetFolder() |
||||
|
||||
out, err := s.service.Create(ctx, &folder.CreateFolderCommand{ |
||||
SignedInUser: user, |
||||
UID: p.Name, |
||||
Title: p.Spec.Title, |
||||
Description: p.Spec.Description, |
||||
OrgID: info.OrgID, |
||||
ParentUID: parent, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return s.Get(ctx, out.UID, nil) |
||||
} |
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context, |
||||
name string, |
||||
objInfo rest.UpdatedObjectInfo, |
||||
createValidation rest.ValidateObjectFunc, |
||||
updateValidation rest.ValidateObjectUpdateFunc, |
||||
forceAllowCreate bool, |
||||
options *metav1.UpdateOptions, |
||||
) (runtime.Object, bool, error) { |
||||
info, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
created := false |
||||
oldObj, err := s.Get(ctx, name, nil) |
||||
if err != nil { |
||||
return oldObj, created, err |
||||
} |
||||
|
||||
obj, err := objInfo.UpdatedObject(ctx, oldObj) |
||||
if err != nil { |
||||
return oldObj, created, err |
||||
} |
||||
f, ok := obj.(*v0alpha1.Folder) |
||||
if !ok { |
||||
return nil, created, fmt.Errorf("expected folder after update") |
||||
} |
||||
old, ok := oldObj.(*v0alpha1.Folder) |
||||
if !ok { |
||||
return nil, created, fmt.Errorf("expected old object to be a folder also") |
||||
} |
||||
|
||||
oldParent := kinds.MetaAccessor(old).GetFolder() |
||||
newParent := kinds.MetaAccessor(f).GetFolder() |
||||
if oldParent != newParent { |
||||
_, err = s.service.Move(ctx, &folder.MoveFolderCommand{ |
||||
SignedInUser: user, |
||||
UID: name, |
||||
OrgID: info.OrgID, |
||||
NewParentUID: newParent, |
||||
}) |
||||
if err != nil { |
||||
return nil, created, fmt.Errorf("error changing parent folder spec") |
||||
} |
||||
} |
||||
|
||||
changed := false |
||||
cmd := &folder.UpdateFolderCommand{ |
||||
SignedInUser: user, |
||||
UID: name, |
||||
OrgID: info.OrgID, |
||||
Overwrite: true, |
||||
} |
||||
if f.Spec.Title != old.Spec.Title { |
||||
cmd.NewTitle = &f.Spec.Title |
||||
changed = true |
||||
} |
||||
if f.Spec.Description != old.Spec.Description { |
||||
cmd.NewDescription = &f.Spec.Description |
||||
changed = true |
||||
} |
||||
if changed { |
||||
_, err = s.service.Update(ctx, cmd) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
} |
||||
|
||||
r, err := s.Get(ctx, name, nil) |
||||
return r, created, err |
||||
} |
||||
|
||||
// GracefulDeleter
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { |
||||
v, err := s.Get(ctx, name, &metav1.GetOptions{}) |
||||
if err != nil { |
||||
return v, false, err // includes the not-found error
|
||||
} |
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
info, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
p, ok := v.(*v0alpha1.Folder) |
||||
if !ok { |
||||
return v, false, fmt.Errorf("expected a folder response from Get") |
||||
} |
||||
err = s.service.Delete(ctx, &folder.DeleteFolderCommand{ |
||||
UID: name, |
||||
OrgID: info.OrgID, |
||||
SignedInUser: user, |
||||
|
||||
// This would cascade delete into alert rules
|
||||
ForceDeleteRules: false, |
||||
}) |
||||
return p, true, err // true is instant delete
|
||||
} |
||||
@ -0,0 +1,162 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
"k8s.io/apimachinery/pkg/runtime/serializer" |
||||
"k8s.io/apiserver/pkg/authorization/authorizer" |
||||
"k8s.io/apiserver/pkg/registry/generic" |
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
genericapiserver "k8s.io/apiserver/pkg/server" |
||||
common "k8s.io/kube-openapi/pkg/common" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/kinds" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/folder" |
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" |
||||
grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
) |
||||
|
||||
var _ grafanaapiserver.APIGroupBuilder = (*FolderAPIBuilder)(nil) |
||||
|
||||
var resourceInfo = v0alpha1.FolderResourceInfo |
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type FolderAPIBuilder struct { |
||||
gv schema.GroupVersion |
||||
features *featuremgmt.FeatureManager |
||||
namespacer request.NamespaceMapper |
||||
folderSvc folder.Service |
||||
} |
||||
|
||||
func RegisterAPIService(cfg *setting.Cfg, |
||||
features *featuremgmt.FeatureManager, |
||||
apiregistration grafanaapiserver.APIRegistrar, |
||||
folderSvc folder.Service, |
||||
) *FolderAPIBuilder { |
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { |
||||
return nil // skip registration unless opting into experimental apis
|
||||
} |
||||
|
||||
builder := &FolderAPIBuilder{ |
||||
gv: resourceInfo.GroupVersion(), |
||||
features: features, |
||||
namespacer: request.GetNamespaceMapper(cfg), |
||||
folderSvc: folderSvc, |
||||
} |
||||
apiregistration.RegisterAPI(builder) |
||||
return builder |
||||
} |
||||
|
||||
func (b *FolderAPIBuilder) GetGroupVersion() schema.GroupVersion { |
||||
return b.gv |
||||
} |
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { |
||||
scheme.AddKnownTypes(gv, |
||||
&v0alpha1.Folder{}, |
||||
&v0alpha1.FolderList{}, |
||||
&v0alpha1.FolderInfo{}, |
||||
) |
||||
} |
||||
|
||||
func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { |
||||
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 *FolderAPIBuilder) GetAPIGroupInfo( |
||||
scheme *runtime.Scheme, |
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter, |
||||
) (*genericapiserver.APIGroupInfo, error) { |
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) |
||||
|
||||
strategy := grafanaregistry.NewStrategy(scheme) |
||||
store := &genericregistry.Store{ |
||||
NewFunc: resourceInfo.NewFunc, |
||||
NewListFunc: resourceInfo.NewListFunc, |
||||
PredicateFunc: grafanaregistry.Matcher, |
||||
DefaultQualifiedResource: resourceInfo.GroupResource(), |
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(), |
||||
CreateStrategy: strategy, |
||||
UpdateStrategy: strategy, |
||||
DeleteStrategy: strategy, |
||||
} |
||||
store.TableConvertor = utils.NewTableConverter( |
||||
store.DefaultQualifiedResource, |
||||
[]metav1.TableColumnDefinition{ |
||||
{Name: "Name", Type: "string", Format: "name"}, |
||||
{Name: "Title", Type: "string", Format: "string", Description: "The display name"}, |
||||
{Name: "Parent", Type: "string", Format: "string", Description: "Parent folder UID"}, |
||||
}, |
||||
func(obj any) ([]interface{}, error) { |
||||
r, ok := obj.(*v0alpha1.Folder) |
||||
if ok { |
||||
accessor := kinds.MetaAccessor(r) |
||||
return []interface{}{ |
||||
r.Name, |
||||
r.Spec.Title, |
||||
accessor.GetFolder(), |
||||
}, nil |
||||
} |
||||
return nil, fmt.Errorf("expected resource or info") |
||||
}) |
||||
legacyStore := &legacyStorage{ |
||||
service: b.folderSvc, |
||||
namespacer: b.namespacer, |
||||
tableConverter: store.TableConvertor, |
||||
} |
||||
|
||||
storage := map[string]rest.Storage{} |
||||
storage[resourceInfo.StoragePath()] = legacyStore |
||||
storage[resourceInfo.StoragePath("parents")] = &subParentsREST{b.folderSvc} |
||||
storage[resourceInfo.StoragePath("children")] = &subChildrenREST{b.folderSvc} |
||||
|
||||
// enable dual writes if a RESTOptionsGetter is provided
|
||||
if optsGetter != nil { |
||||
store, err := newStorage(scheme, optsGetter, legacyStore) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(legacyStore, store) |
||||
} |
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage |
||||
return &apiGroupInfo, nil |
||||
} |
||||
|
||||
func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { |
||||
return v0alpha1.GetOpenAPIDefinitions |
||||
} |
||||
|
||||
func (b *FolderAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { |
||||
return nil // no custom API routes
|
||||
} |
||||
|
||||
func (b *FolderAPIBuilder) GetAuthorizer() authorizer.Authorizer { |
||||
return nil // TODO: the FGAC rules encoded in the service can be moved here
|
||||
} |
||||
@ -0,0 +1,40 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/generic" |
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic" |
||||
grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" |
||||
) |
||||
|
||||
var _ grafanarest.Storage = (*storage)(nil) |
||||
|
||||
type storage struct { |
||||
*genericregistry.Store |
||||
} |
||||
|
||||
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*storage, error) { |
||||
strategy := grafanaregistry.NewStrategy(scheme) |
||||
|
||||
resource := v0alpha1.FolderResourceInfo |
||||
store := &genericregistry.Store{ |
||||
NewFunc: resource.NewFunc, |
||||
NewListFunc: resource.NewListFunc, |
||||
PredicateFunc: grafanaregistry.Matcher, |
||||
DefaultQualifiedResource: resource.GroupResource(), |
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(), |
||||
TableConvertor: legacy.tableConverter, |
||||
|
||||
CreateStrategy: strategy, |
||||
UpdateStrategy: strategy, |
||||
DeleteStrategy: strategy, |
||||
} |
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} |
||||
if err := store.CompleteWithOptions(options); err != nil { |
||||
return nil, err |
||||
} |
||||
return &storage{Store: store}, nil |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/infra/appcontext" |
||||
"github.com/grafana/grafana/pkg/services/folder" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
) |
||||
|
||||
type subChildrenREST struct { |
||||
service folder.Service |
||||
} |
||||
|
||||
var _ = rest.Connecter(&subChildrenREST{}) |
||||
|
||||
func (r *subChildrenREST) New() runtime.Object { |
||||
return &v0alpha1.FolderInfo{} |
||||
} |
||||
|
||||
func (r *subChildrenREST) Destroy() { |
||||
} |
||||
|
||||
func (r *subChildrenREST) ConnectMethods() []string { |
||||
return []string{"GET"} |
||||
} |
||||
|
||||
func (r *subChildrenREST) NewConnectOptions() (runtime.Object, bool, string) { |
||||
return nil, false, "" // true means you can use the trailing path as a variable
|
||||
} |
||||
|
||||
func (r *subChildrenREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { |
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
||||
ns, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
responder.Error(err) |
||||
return |
||||
} |
||||
|
||||
user, err := appcontext.User(ctx) |
||||
if err != nil { |
||||
responder.Error(err) |
||||
return |
||||
} |
||||
|
||||
children, err := r.service.GetChildren(ctx, &folder.GetChildrenQuery{ |
||||
SignedInUser: user, |
||||
UID: name, |
||||
OrgID: ns.OrgID, |
||||
}) |
||||
if err != nil { |
||||
responder.Error(err) |
||||
return |
||||
} |
||||
|
||||
info := &v0alpha1.FolderInfo{ |
||||
Items: make([]v0alpha1.FolderItem, 0), |
||||
} |
||||
for _, parent := range children { |
||||
info.Items = append(info.Items, v0alpha1.FolderItem{ |
||||
Name: parent.UID, |
||||
Title: parent.Title, |
||||
}) |
||||
} |
||||
responder.Object(http.StatusOK, info) |
||||
}), nil |
||||
} |
||||
@ -0,0 +1,64 @@ |
||||
package folders |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folders/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/folder" |
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" |
||||
) |
||||
|
||||
type subParentsREST struct { |
||||
service folder.Service |
||||
} |
||||
|
||||
var _ = rest.Connecter(&subParentsREST{}) |
||||
|
||||
func (r *subParentsREST) New() runtime.Object { |
||||
return &v0alpha1.FolderInfo{} |
||||
} |
||||
|
||||
func (r *subParentsREST) Destroy() { |
||||
} |
||||
|
||||
func (r *subParentsREST) ConnectMethods() []string { |
||||
return []string{"GET"} |
||||
} |
||||
|
||||
func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) { |
||||
return nil, false, "" // true means you can use the trailing path as a variable
|
||||
} |
||||
|
||||
func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { |
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
||||
ns, err := request.NamespaceInfoFrom(ctx, true) |
||||
if err != nil { |
||||
responder.Error(err) |
||||
return |
||||
} |
||||
|
||||
parents, err := r.service.GetParents(ctx, folder.GetParentsQuery{ |
||||
UID: name, |
||||
OrgID: ns.OrgID, |
||||
}) |
||||
if err != nil { |
||||
responder.Error(err) |
||||
return |
||||
} |
||||
|
||||
info := &v0alpha1.FolderInfo{ |
||||
Items: make([]v0alpha1.FolderItem, 0), |
||||
} |
||||
for _, parent := range parents { |
||||
info.Items = append(info.Items, v0alpha1.FolderItem{ |
||||
Name: parent.UID, |
||||
Title: parent.Title, |
||||
}) |
||||
} |
||||
responder.Object(http.StatusOK, info) |
||||
}), nil |
||||
} |
||||
|
@ -0,0 +1,22 @@ |
||||
package utils |
||||
|
||||
import ( |
||||
"crypto/sha256" |
||||
"encoding/base64" |
||||
"strings" |
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/types" |
||||
) |
||||
|
||||
// Create a stable UID that will be unique across a multi-tenant cluster
|
||||
func CalculateClusterWideUID(obj metav1.Object) types.UID { |
||||
hasher := sha256.New() |
||||
hasher.Write([]byte(obj.GetResourceVersion())) |
||||
hasher.Write([]byte("|")) |
||||
hasher.Write([]byte(obj.GetNamespace())) |
||||
hasher.Write([]byte("|")) |
||||
hasher.Write([]byte(obj.GetName())) |
||||
v := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) |
||||
return types.UID(strings.ReplaceAll(v, "=", "")) |
||||
} |
||||
@ -0,0 +1,76 @@ |
||||
package playlist |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/tests/apis" |
||||
"github.com/grafana/grafana/pkg/tests/testinfra" |
||||
) |
||||
|
||||
func TestFoldersApp(t *testing.T) { |
||||
if testing.Short() { |
||||
t.Skip("skipping integration test") |
||||
} |
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ |
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true, |
||||
EnableFeatureToggles: []string{ |
||||
featuremgmt.FlagGrafanaAPIServer, |
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
|
||||
}, |
||||
}) |
||||
|
||||
t.Run("Check discovery client", func(t *testing.T) { |
||||
disco := helper.NewDiscoveryClient() |
||||
resources, err := disco.ServerResourcesForGroupVersion("folders.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": "folders.grafana.app/v0alpha1", |
||||
"resources": [ |
||||
{ |
||||
"name": "folders", |
||||
"singularName": "folder", |
||||
"namespaced": true, |
||||
"kind": "Folder", |
||||
"verbs": [ |
||||
"create", |
||||
"delete", |
||||
"get", |
||||
"list", |
||||
"patch", |
||||
"update" |
||||
] |
||||
}, |
||||
{ |
||||
"name": "folders/children", |
||||
"singularName": "", |
||||
"namespaced": true, |
||||
"kind": "FolderInfo", |
||||
"verbs": [ |
||||
"get" |
||||
] |
||||
}, |
||||
{ |
||||
"name": "folders/parents", |
||||
"singularName": "", |
||||
"namespaced": true, |
||||
"kind": "FolderInfo", |
||||
"verbs": [ |
||||
"get" |
||||
] |
||||
} |
||||
] |
||||
}`, string(v1Disco)) |
||||
}) |
||||
} |
||||
@ -0,0 +1,7 @@ |
||||
apiVersion: folders.grafana.app/v0alpha1 |
||||
kind: Folder |
||||
metadata: |
||||
generateName: x # anything is ok here... except yes or true -- they become boolean! |
||||
spec: |
||||
title: Generated folder title (${RAND}) |
||||
description: A description from here |
||||
Loading…
Reference in new issue