mirror of https://github.com/grafana/grafana
PoC: Define userstorage API (#95557)
parent
c3571752b6
commit
c3494614e3
@ -0,0 +1,6 @@ |
||||
// +k8s:deepcopy-gen=package
|
||||
// +k8s:openapi-gen=true
|
||||
// +k8s:defaulter-gen=TypeMeta
|
||||
// +groupName=userstorage.grafana.com
|
||||
|
||||
package v0alpha1 |
||||
@ -0,0 +1,71 @@ |
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
"fmt" |
||||
"time" |
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
) |
||||
|
||||
const ( |
||||
GROUP = "userstorage.grafana.app" |
||||
VERSION = "v0alpha1" |
||||
APIVERSION = GROUP + "/" + VERSION |
||||
) |
||||
|
||||
var UserStorageResourceInfo = utils.NewResourceInfo(GROUP, VERSION, |
||||
"user-storage", "user-storage", "UserStorage", |
||||
func() runtime.Object { return &UserStorage{} }, |
||||
func() runtime.Object { return &UserStorageList{} }, |
||||
utils.TableColumns{ |
||||
Definition: []metav1.TableColumnDefinition{ |
||||
{Name: "Name", Type: "string", Format: "name"}, |
||||
{Name: "Data", Type: "string"}, |
||||
{Name: "Created At", Type: "date"}, |
||||
}, |
||||
Reader: func(obj any) ([]interface{}, error) { |
||||
m, ok := obj.(*UserStorage) |
||||
if !ok { |
||||
return nil, fmt.Errorf("plugin-storage") |
||||
} |
||||
return []interface{}{ |
||||
m.Name, |
||||
m.Spec.Data, |
||||
m.CreationTimestamp.UTC().Format(time.RFC3339), |
||||
}, nil |
||||
}, |
||||
}, // default table converter
|
||||
) |
||||
|
||||
var ( |
||||
// SchemeGroupVersion is group version used to register these objects
|
||||
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} |
||||
|
||||
// SchemeBuilder is used by standard codegen
|
||||
SchemeBuilder runtime.SchemeBuilder |
||||
localSchemeBuilder = &SchemeBuilder |
||||
AddToScheme = localSchemeBuilder.AddToScheme |
||||
) |
||||
|
||||
func init() { |
||||
localSchemeBuilder.Register(addKnownTypes) |
||||
} |
||||
|
||||
// Adds the list of known types to the given scheme.
|
||||
func addKnownTypes(scheme *runtime.Scheme) error { |
||||
scheme.AddKnownTypes(SchemeGroupVersion, |
||||
&UserStorage{}, |
||||
&UserStorageList{}, |
||||
) |
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion) |
||||
return nil |
||||
} |
||||
|
||||
// Resource takes an unqualified resource and returns a Group qualified GroupResource
|
||||
func Resource(resource string) schema.GroupResource { |
||||
return SchemeGroupVersion.WithResource(resource).GroupResource() |
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
) |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type UserStorage struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
Spec UserStorageSpec `json:"spec,omitempty"` |
||||
} |
||||
|
||||
type UserStorageSpec struct { |
||||
// Data is the key:value stored in the user storage for a service.
|
||||
Data map[string]string `json:"data"` |
||||
} |
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type UserStorageList struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
metav1.ListMeta `json:"metadata,omitempty"` |
||||
|
||||
Items []UserStorage `json:"items,omitempty"` |
||||
} |
||||
@ -0,0 +1,95 @@ |
||||
//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 *UserStorage) DeepCopyInto(out *UserStorage) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
in.Spec.DeepCopyInto(&out.Spec) |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStorage.
|
||||
func (in *UserStorage) DeepCopy() *UserStorage { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(UserStorage) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *UserStorage) 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 *UserStorageList) DeepCopyInto(out *UserStorageList) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ListMeta.DeepCopyInto(&out.ListMeta) |
||||
if in.Items != nil { |
||||
in, out := &in.Items, &out.Items |
||||
*out = make([]UserStorage, len(*in)) |
||||
for i := range *in { |
||||
(*in)[i].DeepCopyInto(&(*out)[i]) |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStorageList.
|
||||
func (in *UserStorageList) DeepCopy() *UserStorageList { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(UserStorageList) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *UserStorageList) 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 *UserStorageSpec) DeepCopyInto(out *UserStorageSpec) { |
||||
*out = *in |
||||
if in.Data != nil { |
||||
in, out := &in.Data, &out.Data |
||||
*out = make(map[string]string, len(*in)) |
||||
for key, val := range *in { |
||||
(*out)[key] = val |
||||
} |
||||
} |
||||
return |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStorageSpec.
|
||||
func (in *UserStorageSpec) DeepCopy() *UserStorageSpec { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(UserStorageSpec) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
@ -0,0 +1,19 @@ |
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by defaulter-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
runtime "k8s.io/apimachinery/pkg/runtime" |
||||
) |
||||
|
||||
// RegisterDefaults adds defaulters functions to the given scheme.
|
||||
// Public to allow building arbitrary schemes.
|
||||
// All generated defaulters are covering - they call all nested defaulters.
|
||||
func RegisterDefaults(scheme *runtime.Scheme) error { |
||||
return nil |
||||
} |
||||
@ -0,0 +1,137 @@ |
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by openapi-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1 |
||||
|
||||
import ( |
||||
common "k8s.io/kube-openapi/pkg/common" |
||||
spec "k8s.io/kube-openapi/pkg/validation/spec" |
||||
) |
||||
|
||||
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { |
||||
return map[string]common.OpenAPIDefinition{ |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorage": schema_pkg_apis_userstorage_v0alpha1_UserStorage(ref), |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorageList": schema_pkg_apis_userstorage_v0alpha1_UserStorageList(ref), |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorageSpec": schema_pkg_apis_userstorage_v0alpha1_UserStorageSpec(ref), |
||||
} |
||||
} |
||||
|
||||
func schema_pkg_apis_userstorage_v0alpha1_UserStorage(ref common.ReferenceCallback) common.OpenAPIDefinition { |
||||
return common.OpenAPIDefinition{ |
||||
Schema: spec.Schema{ |
||||
SchemaProps: spec.SchemaProps{ |
||||
Type: []string{"object"}, |
||||
Properties: map[string]spec.Schema{ |
||||
"kind": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", |
||||
Type: []string{"string"}, |
||||
Format: "", |
||||
}, |
||||
}, |
||||
"apiVersion": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", |
||||
Type: []string{"string"}, |
||||
Format: "", |
||||
}, |
||||
}, |
||||
"metadata": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Default: map[string]interface{}{}, |
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), |
||||
}, |
||||
}, |
||||
"spec": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Default: map[string]interface{}{}, |
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorageSpec"), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Dependencies: []string{ |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorageSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, |
||||
} |
||||
} |
||||
|
||||
func schema_pkg_apis_userstorage_v0alpha1_UserStorageList(ref common.ReferenceCallback) common.OpenAPIDefinition { |
||||
return common.OpenAPIDefinition{ |
||||
Schema: spec.Schema{ |
||||
SchemaProps: spec.SchemaProps{ |
||||
Type: []string{"object"}, |
||||
Properties: map[string]spec.Schema{ |
||||
"kind": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", |
||||
Type: []string{"string"}, |
||||
Format: "", |
||||
}, |
||||
}, |
||||
"apiVersion": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", |
||||
Type: []string{"string"}, |
||||
Format: "", |
||||
}, |
||||
}, |
||||
"metadata": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Default: map[string]interface{}{}, |
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), |
||||
}, |
||||
}, |
||||
"items": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Type: []string{"array"}, |
||||
Items: &spec.SchemaOrArray{ |
||||
Schema: &spec.Schema{ |
||||
SchemaProps: spec.SchemaProps{ |
||||
Default: map[string]interface{}{}, |
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorage"), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Dependencies: []string{ |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1.UserStorage", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, |
||||
} |
||||
} |
||||
|
||||
func schema_pkg_apis_userstorage_v0alpha1_UserStorageSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { |
||||
return common.OpenAPIDefinition{ |
||||
Schema: spec.Schema{ |
||||
SchemaProps: spec.SchemaProps{ |
||||
Type: []string{"object"}, |
||||
Properties: map[string]spec.Schema{ |
||||
"data": { |
||||
SchemaProps: spec.SchemaProps{ |
||||
Description: "Data is the key:value stored in the user storage for a service.", |
||||
Type: []string{"object"}, |
||||
AdditionalProperties: &spec.SchemaOrBool{ |
||||
Allows: true, |
||||
Schema: &spec.Schema{ |
||||
SchemaProps: spec.SchemaProps{ |
||||
Default: "", |
||||
Type: []string{"string"}, |
||||
Format: "", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
Required: []string{"data"}, |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
@ -0,0 +1,118 @@ |
||||
package userstorage |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
"k8s.io/apiserver/pkg/authorization/authorizer" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
genericapiserver "k8s.io/apiserver/pkg/server" |
||||
"k8s.io/kube-openapi/pkg/common" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity" |
||||
userstorage "github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
) |
||||
|
||||
var _ builder.APIGroupBuilder = (*UserStorageAPIBuilder)(nil) |
||||
|
||||
type UserStorageAPIBuilder struct { |
||||
registerer prometheus.Registerer |
||||
} |
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, registerer prometheus.Registerer) *UserStorageAPIBuilder { |
||||
if !features.IsEnabledGlobally(featuremgmt.FlagUserStorageAPI) { |
||||
return nil |
||||
} |
||||
|
||||
builder := &UserStorageAPIBuilder{ |
||||
registerer: registerer, |
||||
} |
||||
apiregistration.RegisterAPI(builder) |
||||
return builder |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) GetGroupVersion() schema.GroupVersion { |
||||
return userstorage.SchemeGroupVersion |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { |
||||
gv := userstorage.SchemeGroupVersion |
||||
err := userstorage.AddToScheme(scheme) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// 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: userstorage.GROUP,
|
||||
// Version: runtime.APIVersionInternal,
|
||||
// })
|
||||
metav1.AddToGroupVersion(scheme, gv) |
||||
return scheme.SetVersionPriority(gv) |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { |
||||
resourceInfo := userstorage.UserStorageResourceInfo |
||||
storage := map[string]rest.Storage{} |
||||
|
||||
storageReg, err := newStorage(opts.Scheme, opts.OptsGetter, b.registerer) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
storage[resourceInfo.StoragePath()] = storageReg |
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[userstorage.VERSION] = storage |
||||
return nil |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { |
||||
return userstorage.GetOpenAPIDefinitions |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) GetAPIRoutes() *builder.APIRoutes { |
||||
return nil |
||||
} |
||||
|
||||
func (b *UserStorageAPIBuilder) GetAuthorizer() authorizer.Authorizer { |
||||
return authorizer.AuthorizerFunc( |
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { |
||||
if !attr.IsResourceRequest() { |
||||
return authorizer.DecisionNoOpinion, "", nil |
||||
} |
||||
|
||||
// require a user
|
||||
u, err := identity.GetRequester(ctx) |
||||
if err != nil { |
||||
return authorizer.DecisionDeny, "valid user is required", err |
||||
} |
||||
|
||||
// check if is admin
|
||||
if u.GetIsGrafanaAdmin() { |
||||
return authorizer.DecisionAllow, "", nil |
||||
} |
||||
|
||||
switch attr.GetVerb() { |
||||
case "create": |
||||
// Create requests are validated later since we don't have access to the resource name
|
||||
return authorizer.DecisionNoOpinion, "", nil |
||||
case "get", "delete", "patch", "update": |
||||
// Only allow the user to access their own settings
|
||||
if !compareResourceNameAndUserUID(attr.GetName(), u) { |
||||
return authorizer.DecisionDeny, "forbidden", nil |
||||
} |
||||
return authorizer.DecisionAllow, "", nil |
||||
default: |
||||
// Forbid the rest
|
||||
return authorizer.DecisionDeny, "forbidden", nil |
||||
} |
||||
}) |
||||
} |
||||
@ -0,0 +1,93 @@ |
||||
package userstorage |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity" |
||||
"github.com/stretchr/testify/assert" |
||||
"k8s.io/apiserver/pkg/authorization/authorizer" |
||||
) |
||||
|
||||
func TestAuthorizer(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
requesterID string |
||||
verb string |
||||
objectName string |
||||
decision authorizer.Decision |
||||
}{ |
||||
{ |
||||
name: "valid authorization", |
||||
requesterID: "123", |
||||
objectName: "user:123", |
||||
verb: "get", |
||||
decision: authorizer.DecisionAllow, |
||||
}, |
||||
{ |
||||
name: "invalid user", |
||||
requesterID: "123", |
||||
objectName: "user:456", |
||||
verb: "get", |
||||
decision: authorizer.DecisionDeny, |
||||
}, |
||||
{ |
||||
name: "admin user", |
||||
requesterID: "admin", |
||||
objectName: "", |
||||
verb: "list", |
||||
decision: authorizer.DecisionAllow, |
||||
}, |
||||
{ |
||||
name: "create request", |
||||
requesterID: "123", |
||||
objectName: "", |
||||
verb: "create", |
||||
decision: authorizer.DecisionNoOpinion, |
||||
}, |
||||
{ |
||||
name: "forbidden action", |
||||
requesterID: "123", |
||||
objectName: "", |
||||
verb: "list", |
||||
decision: authorizer.DecisionDeny, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
requester := &identity.StaticRequester{Type: "user", UserUID: tt.requesterID} |
||||
if tt.requesterID == "admin" { |
||||
requester.IsGrafanaAdmin = true |
||||
} |
||||
ctx := identity.WithRequester(context.Background(), requester) |
||||
apiBuilder := &UserStorageAPIBuilder{} |
||||
auth := apiBuilder.GetAuthorizer() |
||||
at := &fakeAttributes{ |
||||
verb: tt.verb, |
||||
name: tt.objectName, |
||||
} |
||||
decision, _, err := auth.Authorize(ctx, at) |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, tt.decision, decision) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
type fakeAttributes struct { |
||||
authorizer.Attributes |
||||
verb string |
||||
name string |
||||
} |
||||
|
||||
func (a fakeAttributes) GetVerb() string { |
||||
return a.verb |
||||
} |
||||
|
||||
func (a fakeAttributes) IsResourceRequest() bool { |
||||
return true |
||||
} |
||||
|
||||
func (a fakeAttributes) GetName() string { |
||||
return a.name |
||||
} |
||||
@ -0,0 +1,43 @@ |
||||
package userstorage |
||||
|
||||
import ( |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apiserver/pkg/registry/generic" |
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" |
||||
|
||||
userstorage "github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1" |
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" |
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
) |
||||
|
||||
var _ grafanarest.Storage = (*storage)(nil) |
||||
|
||||
type storage struct { |
||||
*genericregistry.Store |
||||
} |
||||
|
||||
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, registerer prometheus.Registerer) (*storage, error) { |
||||
resourceInfo := userstorage.UserStorageResourceInfo |
||||
strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) |
||||
storageStrategy := newStrategy(scheme, resourceInfo.GroupVersion(), registerer) |
||||
|
||||
store := &genericregistry.Store{ |
||||
NewFunc: resourceInfo.NewFunc, |
||||
NewListFunc: resourceInfo.NewListFunc, |
||||
KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), |
||||
KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), |
||||
PredicateFunc: grafanaregistry.Matcher, |
||||
DefaultQualifiedResource: resourceInfo.GroupResource(), |
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(), |
||||
TableConvertor: resourceInfo.TableConverter(), |
||||
CreateStrategy: storageStrategy, |
||||
UpdateStrategy: storageStrategy, |
||||
DeleteStrategy: strategy, |
||||
} |
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} |
||||
if err := store.CompleteWithOptions(options); err != nil { |
||||
return nil, err |
||||
} |
||||
return &storage{Store: store}, nil |
||||
} |
||||
@ -0,0 +1,131 @@ |
||||
package userstorage |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
"k8s.io/apimachinery/pkg/util/validation/field" |
||||
"k8s.io/apiserver/pkg/registry/rest" |
||||
) |
||||
|
||||
var ( |
||||
// Target for user storage size < 3MB
|
||||
userstorageSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ |
||||
Namespace: "userstorage", |
||||
Name: "object_size_bytes", |
||||
Help: "Histogram of user storage object sizes in bytes, broken down by service name", |
||||
Buckets: prometheus.ExponentialBucketsRange(1024, 8*1024*1024, 8), // From 1 KB to 8 MB
|
||||
}, []string{"service"}) |
||||
) |
||||
|
||||
type genericStrategy interface { |
||||
rest.RESTCreateStrategy |
||||
rest.RESTUpdateStrategy |
||||
} |
||||
|
||||
type userstorageStrategy struct { |
||||
genericStrategy |
||||
|
||||
registerer prometheus.Registerer |
||||
} |
||||
|
||||
var once sync.Once |
||||
|
||||
func newStrategy(typer runtime.ObjectTyper, gv schema.GroupVersion, registerer prometheus.Registerer) *userstorageStrategy { |
||||
once.Do(func() { |
||||
if registerer != nil { |
||||
registerer.MustRegister( |
||||
userstorageSize, |
||||
) |
||||
} |
||||
}) |
||||
genericStrategy := grafanaregistry.NewStrategy(typer, gv) |
||||
return &userstorageStrategy{genericStrategy, registerer} |
||||
} |
||||
|
||||
func compareResourceNameAndUserUID(name string, u identity.Requester) bool { |
||||
parsedName, err := parseName(name) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
// u.GetUID() returns user:<user_uid> so we need to remove the user: prefix
|
||||
userUID := strings.Split(u.GetUID(), ":") |
||||
if len(userUID) != 2 { |
||||
return false |
||||
} |
||||
|
||||
return parsedName.UID == userUID[1] |
||||
} |
||||
|
||||
func registerSize(obj runtime.Object) { |
||||
meta, err := utils.MetaAccessor(obj) |
||||
if err != nil { |
||||
return |
||||
} |
||||
|
||||
parsedName, err := parseName(meta.GetName()) |
||||
if err != nil { |
||||
return |
||||
} |
||||
|
||||
b := new(bytes.Buffer) |
||||
if err := json.NewEncoder(b).Encode(obj); err != nil { |
||||
return |
||||
} |
||||
userstorageSize.WithLabelValues(parsedName.Service).Observe(float64(b.Len())) |
||||
} |
||||
|
||||
// Validate ensures that when creating a userstorage object, the name matches the user id.
|
||||
func (g *userstorageStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { |
||||
u, err := identity.GetRequester(ctx) |
||||
if err != nil { |
||||
return field.ErrorList{field.InternalError(nil, fmt.Errorf("failed to get requester: %v", err))} |
||||
} |
||||
|
||||
meta, err := utils.MetaAccessor(obj) |
||||
if err != nil { |
||||
return field.ErrorList{field.InternalError(nil, fmt.Errorf("failed to get meta accessor: %v", err))} |
||||
} |
||||
|
||||
nameMatch := compareResourceNameAndUserUID(meta.GetName(), u) |
||||
if !nameMatch { |
||||
return field.ErrorList{field.Forbidden(field.NewPath("metadata").Child("name"), "name must match service:user_uid")} |
||||
} |
||||
|
||||
registerSize(obj) |
||||
return field.ErrorList{} |
||||
} |
||||
|
||||
func (g *userstorageStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { |
||||
registerSize(obj) |
||||
return field.ErrorList{} |
||||
} |
||||
|
||||
type storageObjectName struct { |
||||
Service string |
||||
UID string |
||||
} |
||||
|
||||
func parseName(name string) (*storageObjectName, error) { |
||||
vals := strings.Split(name, ":") |
||||
if len(vals) != 2 { |
||||
return nil, errors.New("name must be in the format <service>:<user_uid>") |
||||
} |
||||
|
||||
return &storageObjectName{ |
||||
Service: vals[0], |
||||
UID: vals[1], |
||||
}, nil |
||||
} |
||||
@ -0,0 +1,56 @@ |
||||
package userstorage |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity" |
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"github.com/stretchr/testify/assert" |
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
) |
||||
|
||||
func TestValidate(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
requesterID string |
||||
objectName string |
||||
expectError bool |
||||
}{ |
||||
{ |
||||
name: "valid userstorage object", |
||||
requesterID: "123", |
||||
objectName: "basic-panel:123", |
||||
expectError: false, |
||||
}, |
||||
{ |
||||
name: "invalid userstorage object", |
||||
requesterID: "123", |
||||
objectName: "basic-panel:456", |
||||
expectError: true, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
requester := &identity.StaticRequester{Type: "user", UserUID: tt.requesterID} |
||||
obj := &v0alpha1.UserStorage{ |
||||
ObjectMeta: v1.ObjectMeta{ |
||||
Name: tt.objectName, |
||||
}, |
||||
} |
||||
ctx := identity.WithRequester(context.Background(), requester) |
||||
|
||||
strategy := newStrategy(nil, schema.GroupVersion{}, prometheus.DefaultRegisterer) |
||||
errs := strategy.Validate(ctx, obj) |
||||
|
||||
if tt.expectError { |
||||
assert.NotEmpty(t, errs) |
||||
} else { |
||||
assert.Empty(t, errs) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue