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