K8s/Folders: Add folders api service (with legacy storage) (#79413)

This commit is contained in:
Ryan McKinley 2023-12-20 10:28:56 -08:00 committed by GitHub
parent 360de108ec
commit 67bbdd7c05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3819 additions and 391 deletions

View File

@ -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 |

View File

@ -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"

View File

@ -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
//
}
}]

View File

@ -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,

View File

@ -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;
}

View 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"

View 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"`
}

View 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
}

View 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
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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

View File

@ -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",

View File

@ -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{}
}

View 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)))
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@ -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) {

View File

@ -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": {

View File

@ -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,
)

View File

@ -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))

View File

@ -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",

View File

@ -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

1 Name Stage Owner Created requiresDevMode RequiresLicense RequiresRestart FrontendOnly
83 mlExpressions experimental @grafana/alerting-squad 2023-07-13 false false false false
84 traceQLStreaming experimental @grafana/observability-traces-and-profiling 2023-07-26 false false false true
85 metricsSummary experimental @grafana/observability-traces-and-profiling 2023-08-28 false false false true
86 grafanaAPIServer experimental @grafana/grafana-app-platform-squad 2023-07-14 false false false true false
87 grafanaAPIServerWithExperimentalAPIs experimental @grafana/grafana-app-platform-squad 2023-10-06 false true false false true false
88 grafanaAPIServerEnsureKubectlAccess experimental @grafana/grafana-app-platform-squad 2023-12-06 true false true false
89 featureToggleAdminPage experimental @grafana/grafana-operator-experience-squad 2023-07-18 false false true false
90 awsAsyncQueryCaching preview @grafana/aws-datasources 2023-07-21 false false false false

View 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, "=", ""))
}

View File

@ -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

View File

@ -7,9 +7,6 @@ import context "context"
//-----------------------------------------------------------------------------------------------------
const (
FolderGroupName = "folder.grafana.app"
FolderResourceName = "folders"
StandardKindDashboard = "dashboard"
StandardKindPlaylist = "playlist"
StandardKindFolder = "folder"

View File

@ -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,

View File

@ -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

View File

@ -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,

View 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))
})
}

View 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