mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
K8s/Folders: Add folders api service (with legacy storage) (#79413)
This commit is contained in:
parent
360de108ec
commit
67bbdd7c05
@ -127,7 +127,6 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source |
|
||||
| `metricsSummary` | Enables metrics summary queries in the Tempo data source |
|
||||
| `grafanaAPIServer` | Enable Kubernetes API Server for Grafana resources |
|
||||
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
|
||||
| `featureToggleAdminPage` | Enable admin page for managing feature toggles from the Grafana front-end |
|
||||
| `traceToProfiles` | Enables linking between traces and profiles |
|
||||
| `tracesEmbeddedFlameGraph` | Enables embedding a flame graph in traces |
|
||||
@ -171,10 +170,11 @@ Experimental features might be changed or removed without prior notice.
|
||||
|
||||
The following toggles require explicitly setting Grafana's [app mode]({{< relref "../_index.md#app_mode" >}}) to 'development' before you can enable this feature toggle. These features tend to be experimental.
|
||||
|
||||
| Feature toggle name | Description |
|
||||
| ------------------------------------- | -------------------------------------------------------------- |
|
||||
| `unifiedStorage` | SQL-based k8s storage |
|
||||
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
|
||||
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
|
||||
| `panelTitleSearchInV1` | Enable searching for dashboards using panel title in search v1 |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API |
|
||||
| Feature toggle name | Description |
|
||||
| -------------------------------------- | -------------------------------------------------------------- |
|
||||
| `unifiedStorage` | SQL-based k8s storage |
|
||||
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
|
||||
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
|
||||
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
|
||||
| `panelTitleSearchInV1` | Enable searching for dashboards using panel title in search v1 |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API |
|
||||
|
@ -30,7 +30,7 @@ CLIENTSET_PKG_NAME=clientset \
|
||||
"${CODEGEN_PKG}/generate-groups.sh" "all" \
|
||||
github.com/grafana/grafana/pkg/generated \
|
||||
github.com/grafana/grafana/pkg/apis \
|
||||
"snapshots:v0alpha1" \
|
||||
"folders:v0alpha1" \
|
||||
--output-base "${OUTDIR}" \
|
||||
--go-header-file "${SCRIPT_ROOT}/hack/boilerplate.go.txt"
|
||||
|
||||
@ -40,6 +40,6 @@ CLIENTSET_PKG_NAME=clientset \
|
||||
github.com/grafana/grafana/pkg/generated \
|
||||
github.com/grafana/grafana/pkg/apis \
|
||||
github.com/grafana/grafana/pkg/apis \
|
||||
"snapshots:v0alpha1" \
|
||||
"folders:v0alpha1" \
|
||||
--output-base "${OUTDIR}" \
|
||||
--go-header-file "${SCRIPT_ROOT}/hack/boilerplate.go.txt"
|
||||
|
@ -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
|
||||
//
|
||||
}
|
||||
}]
|
@ -104,9 +104,6 @@ export {
|
||||
defaultRowPanel
|
||||
} from './veneer/dashboard.types';
|
||||
|
||||
// Raw generated types from Folder kind.
|
||||
export type { Folder } from './raw/folder/x/folder_types.gen';
|
||||
|
||||
// Raw generated types from LibraryPanel kind.
|
||||
export type {
|
||||
LibraryElementDTOMetaUser,
|
||||
|
@ -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;
|
||||
}
|
5
pkg/apis/folders/v0alpha1/doc.go
Normal file
5
pkg/apis/folders/v0alpha1/doc.go
Normal file
@ -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"
|
60
pkg/apis/folders/v0alpha1/types.go
Normal file
60
pkg/apis/folders/v0alpha1/types.go
Normal file
@ -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"`
|
||||
}
|
134
pkg/apis/folders/v0alpha1/zz_generated.deepcopy.go
Normal file
134
pkg/apis/folders/v0alpha1/zz_generated.deepcopy.go
Normal file
@ -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
|
||||
}
|
19
pkg/apis/folders/v0alpha1/zz_generated.defaults.go
Normal file
19
pkg/apis/folders/v0alpha1/zz_generated.defaults.go
Normal file
@ -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
|
||||
}
|
2687
pkg/apis/folders/v0alpha1/zz_generated.openapi.go
Normal file
2687
pkg/apis/folders/v0alpha1/zz_generated.openapi.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
@ -50,13 +51,21 @@ func User(ctx context.Context) (*user.SignedInUser, error) {
|
||||
case k8suser.APIServerUser:
|
||||
fallthrough
|
||||
case k8suser.SystemPrivilegedGroup:
|
||||
orgId := int64(1)
|
||||
return &user.SignedInUser{
|
||||
UserID: 1,
|
||||
OrgID: 1,
|
||||
OrgID: orgId,
|
||||
Name: k8sUserInfo.GetName(),
|
||||
Login: k8sUserInfo.GetName(),
|
||||
OrgRole: roletype.RoleAdmin,
|
||||
IsGrafanaAdmin: true,
|
||||
Permissions: map[int64]map[string][]string{
|
||||
orgId: {
|
||||
"*": {"*"},
|
||||
dashboards.ActionFoldersCreate: {"*"}, // all resources, all scopes
|
||||
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -600,31 +600,26 @@
|
||||
},
|
||||
"folder": {
|
||||
"category": "core",
|
||||
"codeowners": [
|
||||
"grafana/grafana-as-code",
|
||||
"grafana/grafana-frontend-platform",
|
||||
"grafana/plugins-platform-frontend"
|
||||
],
|
||||
"codeowners": [],
|
||||
"crd": {
|
||||
"dummySchema": false,
|
||||
"group": "folder.core.grafana.com",
|
||||
"scope": "Namespaced"
|
||||
"group": "",
|
||||
"scope": ""
|
||||
},
|
||||
"currentVersion": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"description": "A folder is a collection of resources that are grouped together and can share permissions.",
|
||||
"grafanaMaturityCount": 0,
|
||||
"lineageIsGroup": false,
|
||||
"links": {
|
||||
"docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/folder/schema-reference",
|
||||
"go": "https://github.com/grafana/grafana/tree/main/pkg/kinds/folder",
|
||||
"schema": "https://github.com/grafana/grafana/tree/main/kinds/folder/folder_kind.cue",
|
||||
"ts": "https://github.com/grafana/grafana/tree/main/packages/grafana-schema/src/raw/folder/x/folder_types.gen.ts"
|
||||
"docs": "n/a",
|
||||
"go": "n/a",
|
||||
"schema": "n/a",
|
||||
"ts": "n/a"
|
||||
},
|
||||
"machineName": "folder",
|
||||
"maturity": "merged",
|
||||
"maturity": "planned",
|
||||
"name": "Folder",
|
||||
"pluralMachineName": "folders",
|
||||
"pluralName": "Folders"
|
||||
@ -2270,7 +2265,6 @@
|
||||
"accesspolicy",
|
||||
"alertgroupspanelcfg",
|
||||
"azuremonitordataquery",
|
||||
"folder",
|
||||
"googlecloudmonitoringdataquery",
|
||||
"heatmappanelcfg",
|
||||
"preferences",
|
||||
@ -2281,7 +2275,7 @@
|
||||
"timeseriespanelcfg",
|
||||
"trendpanelcfg"
|
||||
],
|
||||
"count": 13
|
||||
"count": 12
|
||||
},
|
||||
"planned": {
|
||||
"name": "planned",
|
||||
@ -2297,6 +2291,7 @@
|
||||
"datasource",
|
||||
"elasticsearchdatasourcecfg",
|
||||
"flamegraphpanelcfg",
|
||||
"folder",
|
||||
"gettingstartedpanelcfg",
|
||||
"googlecloudmonitoringdatasourcecfg",
|
||||
"grafanadataquery",
|
||||
@ -2331,7 +2326,7 @@
|
||||
"zipkindataquery",
|
||||
"zipkindatasourcecfg"
|
||||
],
|
||||
"count": 44
|
||||
"count": 45
|
||||
},
|
||||
"stable": {
|
||||
"name": "stable",
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/folders"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/playlist"
|
||||
)
|
||||
|
||||
@ -19,6 +20,7 @@ type Service struct{}
|
||||
func ProvideRegistryServiceSink(
|
||||
_ *playlist.PlaylistAPIBuilder,
|
||||
_ *example.TestingAPIBuilder,
|
||||
_ *folders.FolderAPIBuilder,
|
||||
) *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
53
pkg/registry/apis/folders/continue.go
Normal file
53
pkg/registry/apis/folders/continue.go
Normal file
@ -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)))
|
||||
}
|
26
pkg/registry/apis/folders/continue_test.go
Normal file
26
pkg/registry/apis/folders/continue_test.go
Normal file
@ -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)
|
||||
}
|
46
pkg/registry/apis/folders/conversions.go
Normal file
46
pkg/registry/apis/folders/conversions.go
Normal file
@ -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
|
||||
}
|
272
pkg/registry/apis/folders/legacy_storage.go
Normal file
272
pkg/registry/apis/folders/legacy_storage.go
Normal file
@ -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
|
||||
}
|
162
pkg/registry/apis/folders/register.go
Normal file
162
pkg/registry/apis/folders/register.go
Normal file
@ -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
|
||||
}
|
40
pkg/registry/apis/folders/storage.go
Normal file
40
pkg/registry/apis/folders/storage.go
Normal file
@ -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
|
||||
}
|
72
pkg/registry/apis/folders/sub_children.go
Normal file
72
pkg/registry/apis/folders/sub_children.go
Normal file
@ -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
|
||||
}
|
64
pkg/registry/apis/folders/sub_parents.go
Normal file
64
pkg/registry/apis/folders/sub_parents.go
Normal file
@ -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
|
||||
}
|
@ -13,6 +13,7 @@ import (
|
||||
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/kinds"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
|
||||
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
|
||||
)
|
||||
|
||||
@ -85,7 +86,7 @@ func convertToK8sResource(v *playlistsvc.PlaylistDTO, namespacer request.Namespa
|
||||
Key: fmt.Sprintf("%d", v.Id),
|
||||
})
|
||||
}
|
||||
return &playlist.Playlist{
|
||||
p := &playlist.Playlist{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: v.Uid,
|
||||
UID: types.UID(v.Uid),
|
||||
@ -96,6 +97,8 @@ func convertToK8sResource(v *playlistsvc.PlaylistDTO, namespacer request.Namespa
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
p.UID = utils.CalculateClusterWideUID(p)
|
||||
return p
|
||||
}
|
||||
|
||||
func convertToLegacyUpdateCommand(p *playlist.Playlist, orgId int64) (*playlistsvc.UpdatePlaylistCommand, error) {
|
||||
|
@ -38,7 +38,7 @@ func TestPlaylistConversion(t *testing.T) {
|
||||
"metadata": {
|
||||
"name": "abc",
|
||||
"namespace": "org-3",
|
||||
"uid": "abc",
|
||||
"uid": "Ik_jZSxBTV42xgQIwUsTiVx68S3RzWnzrCUVhHqvaxM",
|
||||
"resourceVersion": "54321",
|
||||
"creationTimestamp": "1970-01-01T00:00:12Z",
|
||||
"annotations": {
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/folders"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/playlist"
|
||||
)
|
||||
|
||||
@ -14,4 +15,5 @@ var WireSet = wire.NewSet(
|
||||
// playlistV0.RegisterAPIService,
|
||||
playlist.RegisterAPIService,
|
||||
example.RegisterAPIService,
|
||||
folders.RegisterAPIService,
|
||||
)
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/kinds/accesspolicy"
|
||||
"github.com/grafana/grafana/pkg/kinds/dashboard"
|
||||
"github.com/grafana/grafana/pkg/kinds/folder"
|
||||
"github.com/grafana/grafana/pkg/kinds/librarypanel"
|
||||
"github.com/grafana/grafana/pkg/kinds/preferences"
|
||||
"github.com/grafana/grafana/pkg/kinds/publicdashboard"
|
||||
@ -44,7 +43,6 @@ type Base struct {
|
||||
all []kindsys.Core
|
||||
accesspolicy *accesspolicy.Kind
|
||||
dashboard *dashboard.Kind
|
||||
folder *folder.Kind
|
||||
librarypanel *librarypanel.Kind
|
||||
preferences *preferences.Kind
|
||||
publicdashboard *publicdashboard.Kind
|
||||
@ -57,7 +55,6 @@ type Base struct {
|
||||
var (
|
||||
_ kindsys.Core = &accesspolicy.Kind{}
|
||||
_ kindsys.Core = &dashboard.Kind{}
|
||||
_ kindsys.Core = &folder.Kind{}
|
||||
_ kindsys.Core = &librarypanel.Kind{}
|
||||
_ kindsys.Core = &preferences.Kind{}
|
||||
_ kindsys.Core = &publicdashboard.Kind{}
|
||||
@ -76,11 +73,6 @@ func (b *Base) Dashboard() *dashboard.Kind {
|
||||
return b.dashboard
|
||||
}
|
||||
|
||||
// Folder returns the [kindsys.Interface] implementation for the folder kind.
|
||||
func (b *Base) Folder() *folder.Kind {
|
||||
return b.folder
|
||||
}
|
||||
|
||||
// LibraryPanel returns the [kindsys.Interface] implementation for the librarypanel kind.
|
||||
func (b *Base) LibraryPanel() *librarypanel.Kind {
|
||||
return b.librarypanel
|
||||
@ -127,12 +119,6 @@ func doNewBase(rt *thema.Runtime) *Base {
|
||||
}
|
||||
reg.all = append(reg.all, reg.dashboard)
|
||||
|
||||
reg.folder, err = folder.NewKind(rt)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error while initializing the folder Kind: %s", err))
|
||||
}
|
||||
reg.all = append(reg.all, reg.folder)
|
||||
|
||||
reg.librarypanel, err = librarypanel.NewKind(rt)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("error while initializing the librarypanel Kind: %s", err))
|
||||
|
@ -716,20 +716,21 @@ var (
|
||||
Created: time.Date(2023, time.August, 28, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Name: "grafanaAPIServer",
|
||||
Description: "Enable Kubernetes API Server for Grafana resources",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
Created: time.Date(2023, time.July, 14, 12, 0, 0, 0, time.UTC),
|
||||
Name: "grafanaAPIServer",
|
||||
Description: "Enable Kubernetes API Server for Grafana resources",
|
||||
Stage: FeatureStageExperimental,
|
||||
RequiresRestart: true,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
Created: time.Date(2023, time.July, 14, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Name: "grafanaAPIServerWithExperimentalAPIs",
|
||||
Description: "Register experimental APIs with the k8s API server",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
Created: time.Date(2023, time.October, 6, 12, 0, 0, 0, time.UTC),
|
||||
Name: "grafanaAPIServerWithExperimentalAPIs",
|
||||
Description: "Register experimental APIs with the k8s API server",
|
||||
Stage: FeatureStageExperimental,
|
||||
RequiresRestart: true,
|
||||
RequiresDevMode: true,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
Created: time.Date(2023, time.October, 6, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Name: "grafanaAPIServerEnsureKubectlAccess",
|
||||
|
@ -83,8 +83,8 @@ transformationsRedesign,GA,@grafana/observability-metrics,2023-07-12,false,false
|
||||
mlExpressions,experimental,@grafana/alerting-squad,2023-07-13,false,false,false,false
|
||||
traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,2023-07-26,false,false,false,true
|
||||
metricsSummary,experimental,@grafana/observability-traces-and-profiling,2023-08-28,false,false,false,true
|
||||
grafanaAPIServer,experimental,@grafana/grafana-app-platform-squad,2023-07-14,false,false,false,false
|
||||
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,2023-10-06,false,false,false,false
|
||||
grafanaAPIServer,experimental,@grafana/grafana-app-platform-squad,2023-07-14,false,false,true,false
|
||||
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,2023-10-06,true,false,true,false
|
||||
grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,2023-12-06,true,false,true,false
|
||||
featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,2023-07-18,false,false,true,false
|
||||
awsAsyncQueryCaching,preview,@grafana/aws-datasources,2023-07-21,false,false,false,false
|
||||
|
|
22
pkg/services/grafana-apiserver/utils/uids.go
Normal file
22
pkg/services/grafana-apiserver/utils/uids.go
Normal file
@ -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, "=", ""))
|
||||
}
|
@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func initEntityTables(mg *migrator.Migrator) string {
|
||||
marker := "Initialize entity tables (v007)" // changing this key wipe+rewrite everything
|
||||
marker := "Initialize entity tables (v008)" // changing this key wipe+rewrite everything
|
||||
mg.AddMigration(marker, &migrator.RawSQLMigration{})
|
||||
|
||||
tables := []migrator.Table{}
|
||||
@ -128,7 +128,6 @@ func initEntityTables(mg *migrator.Migrator) string {
|
||||
{Name: "guid", Type: migrator.DB_NVarchar, Length: 36, Nullable: false, IsPrimaryKey: true},
|
||||
{Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: false},
|
||||
{Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "title", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "slug_path", Type: migrator.DB_Text, Nullable: false}, // /slug/slug/slug/
|
||||
{Name: "tree", Type: migrator.DB_Text, Nullable: false}, // JSON []{uid, title}
|
||||
{Name: "depth", Type: migrator.DB_Int, Nullable: false}, // starts at 1
|
||||
|
@ -7,9 +7,6 @@ import context "context"
|
||||
//-----------------------------------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
FolderGroupName = "folder.grafana.app"
|
||||
FolderResourceName = "folders"
|
||||
|
||||
StandardKindDashboard = "dashboard"
|
||||
StandardKindPlaylist = "playlist"
|
||||
StandardKindFolder = "folder"
|
||||
|
@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
foldersV0 "github.com/grafana/grafana/pkg/apis/folders/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/session"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
)
|
||||
|
||||
type folderInfo struct {
|
||||
@ -34,17 +34,17 @@ type folderInfo struct {
|
||||
// This will replace all entries in `entity_folder`
|
||||
// This is pretty heavy weight, but it does give us a sorted folder list
|
||||
// NOTE: this could be done async with a mutex/lock? reconciler pattern
|
||||
func updateFolderTree(ctx context.Context, tx *session.SessionTx, namespace string) error {
|
||||
func (s *sqlEntityServer) updateFolderTree(ctx context.Context, tx *session.SessionTx, namespace string) error {
|
||||
_, err := tx.Exec(ctx, "DELETE FROM entity_folder WHERE namespace=?", namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := "SELECT guid,uid,folder,name,slug" +
|
||||
query := "SELECT guid,name,folder,name,slug" +
|
||||
" FROM entity" +
|
||||
" WHERE group=? AND resource=? AND namespace=?" +
|
||||
" WHERE " + s.dialect.Quote("group") + "=? AND resource=? AND namespace=?" +
|
||||
" ORDER BY slug asc"
|
||||
args := []interface{}{entity.FolderGroupName, entity.FolderResourceName, namespace}
|
||||
args := []interface{}{foldersV0.GROUP, foldersV0.RESOURCE, namespace}
|
||||
|
||||
all := []*folderInfo{}
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
@ -141,7 +141,7 @@ func insertFolderInfo(ctx context.Context, tx *session.SessionTx, namespace stri
|
||||
js, _ := json.Marshal(folder.stack)
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO entity_folder `+
|
||||
"(guid, namespace, uid, slug_path, tree, depth, lft, rgt, detached) "+
|
||||
"(guid, namespace, name, slug_path, tree, depth, lft, rgt, detached) "+
|
||||
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.Guid,
|
||||
namespace,
|
||||
|
@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/google/uuid"
|
||||
|
||||
foldersV0 "github.com/grafana/grafana/pkg/apis/folders/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
@ -471,10 +473,10 @@ func (s *sqlEntityServer) Create(ctx context.Context, r *entity.CreateEntityRequ
|
||||
}
|
||||
|
||||
switch current.Group {
|
||||
case entity.FolderGroupName:
|
||||
case foldersV0.GROUP:
|
||||
switch current.Resource {
|
||||
case entity.FolderResourceName:
|
||||
err = updateFolderTree(ctx, tx, current.Namespace)
|
||||
case foldersV0.RESOURCE:
|
||||
err = s.updateFolderTree(ctx, tx, current.Namespace)
|
||||
if err != nil {
|
||||
s.log.Error("error updating folder tree", "msg", err.Error())
|
||||
return err
|
||||
@ -707,10 +709,10 @@ func (s *sqlEntityServer) Update(ctx context.Context, r *entity.UpdateEntityRequ
|
||||
}
|
||||
|
||||
switch current.Group {
|
||||
case entity.FolderGroupName:
|
||||
case foldersV0.GROUP:
|
||||
switch current.Resource {
|
||||
case entity.FolderResourceName:
|
||||
err = updateFolderTree(ctx, tx, current.Namespace)
|
||||
case foldersV0.RESOURCE:
|
||||
err = s.updateFolderTree(ctx, tx, current.Namespace)
|
||||
if err != nil {
|
||||
s.log.Error("error updating folder tree", "msg", err.Error())
|
||||
return err
|
||||
@ -825,10 +827,10 @@ func (s *sqlEntityServer) doDelete(ctx context.Context, tx *session.SessionTx, e
|
||||
}
|
||||
|
||||
switch ent.Group {
|
||||
case entity.FolderGroupName:
|
||||
case foldersV0.GROUP:
|
||||
switch ent.Resource {
|
||||
case entity.FolderResourceName:
|
||||
err = updateFolderTree(ctx, tx, ent.Namespace)
|
||||
case foldersV0.RESOURCE:
|
||||
err = s.updateFolderTree(ctx, tx, ent.Namespace)
|
||||
if err != nil {
|
||||
s.log.Error("error updating folder tree", "msg", err.Error())
|
||||
return err
|
||||
|
@ -20,7 +20,7 @@ func TestExampleApp(t *testing.T) {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true, // do not start extra port 6443
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServer,
|
||||
|
76
pkg/tests/apis/folders/folders_test.go
Normal file
76
pkg/tests/apis/folders/folders_test.go
Normal file
@ -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))
|
||||
})
|
||||
}
|
7
pkg/tests/apis/folders/testdata/folder-generate.yaml
vendored
Normal file
7
pkg/tests/apis/folders/testdata/folder-generate.yaml
vendored
Normal file
@ -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
Block a user