Unified storage: Add quotas app to apiserver (#114425)
* initial generation * went through doc to add new resource * added dummy kind so grafana will run * added dummy handler and custom route * fix app name * gets custom route working - still a dummy route * adds groupOverride to manifest * adds quotas to grpc client and server * WIP - trying to get api recognized - not working * Gets route working * fixes group and resource vars * expects group and resource as separate params * set content-type header on response * removes Quotas kind and regens * Update grafana-app-sdk to v0.48.5 * Update codegen * updates manifest * formatting * updates grafana-app-sdk version to 0.48.5 * regen ResourceClient mocks * adds tests * remove commented code * uncomment go mod tidy * fix tests and make update workspace * adds quotas app to codeowners * formatting * make gen-apps * deletes temp file * fix generated folder code * make gofmt * make gen-go * make update-workspace * add COPY apps/quotas to Dockerfile * fix test mock * fixes undefined NewFolderStatus() * make gen-apps, and add func for NewFolderStatus * make gen-apps again * make update-workspace * regen folder_object_gen.go * gofmt * fix linting * apps/folder make update-workspace * make gen-apps * make gen-apps * fixes enterprise_imports.go * go get testcontainers * adds feature toggle * make update-workspace * fix go mod * fix another client mock --------- Co-authored-by: Steve Simpson <steve@grafana.com>
This commit is contained in:
co-authored by
Steve Simpson
parent
297e886e1b
commit
a3daf0e39d
@@ -0,0 +1,33 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type GetUsageRequestParamsObject struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
GetUsageRequestParams `json:",inline"`
|
||||
}
|
||||
|
||||
func NewGetUsageRequestParamsObject() *GetUsageRequestParamsObject {
|
||||
return &GetUsageRequestParamsObject{}
|
||||
}
|
||||
|
||||
func (o *GetUsageRequestParamsObject) DeepCopyObject() runtime.Object {
|
||||
dst := NewGetUsageRequestParamsObject()
|
||||
o.DeepCopyInto(dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (o *GetUsageRequestParamsObject) DeepCopyInto(dst *GetUsageRequestParamsObject) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
dstGetUsageRequestParams := GetUsageRequestParams{}
|
||||
_ = resource.CopyObjectInto(&dstGetUsageRequestParams, &o.GetUsageRequestParams)
|
||||
}
|
||||
|
||||
var _ runtime.Object = NewGetUsageRequestParamsObject()
|
||||
@@ -0,0 +1,13 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
type GetUsageRequestParams struct {
|
||||
Group string `json:"group"`
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
// NewGetUsageRequestParams creates a new GetUsageRequestParams object.
|
||||
func NewGetUsageRequestParams() *GetUsageRequestParams {
|
||||
return &GetUsageRequestParams{}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetUsageBody struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Resource string `json:"resource"`
|
||||
Group string `json:"group"`
|
||||
Usage int64 `json:"usage"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
// NewGetUsageBody creates a new GetUsageBody object.
|
||||
func NewGetUsageBody() *GetUsageBody {
|
||||
return &GetUsageBody{}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetUsage struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
GetUsageBody `json:",inline"`
|
||||
}
|
||||
|
||||
func NewGetUsage() *GetUsage {
|
||||
return &GetUsage{}
|
||||
}
|
||||
|
||||
func (t *GetUsageBody) DeepCopyInto(dst *GetUsageBody) {
|
||||
_ = resource.CopyObjectInto(dst, t)
|
||||
}
|
||||
|
||||
func (o *GetUsage) DeepCopyObject() runtime.Object {
|
||||
dst := NewGetUsage()
|
||||
o.DeepCopyInto(dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (o *GetUsage) DeepCopyInto(dst *GetUsage) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
o.GetUsageBody.DeepCopyInto(&dst.GetUsageBody)
|
||||
}
|
||||
|
||||
var _ runtime.Object = NewGetUsage()
|
||||
Generated
+213
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// This file is generated by grafana-app-sdk
|
||||
// DO NOT EDIT
|
||||
//
|
||||
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/quotas/pkg/apis/quotas/v0alpha1"
|
||||
)
|
||||
|
||||
var appManifestData = app.ManifestData{
|
||||
AppName: "quotas",
|
||||
Group: "quotas.grafana.app",
|
||||
PreferredVersion: "v0alpha1",
|
||||
Versions: []app.ManifestVersion{
|
||||
{
|
||||
Name: "v0alpha1",
|
||||
Served: true,
|
||||
Kinds: []app.ManifestVersionKind{},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{
|
||||
"/usage": {
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
|
||||
OperationId: "getUsage",
|
||||
|
||||
Parameters: []*spec3.Parameter{
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "group",
|
||||
In: "query",
|
||||
Required: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "resource",
|
||||
In: "query",
|
||||
Required: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
Default: &spec3.Response{
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Description: "Default OK response",
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
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",
|
||||
},
|
||||
},
|
||||
"group": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
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",
|
||||
},
|
||||
},
|
||||
"limit": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
},
|
||||
},
|
||||
"namespace": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"resource": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"usage": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"namespace",
|
||||
"resource",
|
||||
"group",
|
||||
"usage",
|
||||
"limit",
|
||||
"apiVersion",
|
||||
"kind",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
Schemas: map[string]spec.Schema{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func LocalManifest() app.Manifest {
|
||||
return app.NewEmbeddedManifest(appManifestData)
|
||||
}
|
||||
|
||||
func RemoteManifest() app.Manifest {
|
||||
return app.NewAPIServerManifest("quotas")
|
||||
}
|
||||
|
||||
var kindVersionToGoType = map[string]resource.Kind{}
|
||||
|
||||
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
|
||||
// If there is no association for the provided Kind and Version, exists will return false.
|
||||
func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) {
|
||||
goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)]
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoResponseType = map[string]any{
|
||||
"v0alpha1||<namespace>/usage|GET": v0alpha1.GetUsage{},
|
||||
}
|
||||
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths.
|
||||
// If there is no association for the provided kind, version, custom route path, and method, exists will return false.
|
||||
// Resource routes (those without a kind) should prefix their route with "<namespace>/" if the route is namespaced (otherwise the route is assumed to be cluster-scope)
|
||||
func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))]
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoParamsType = map[string]runtime.Object{}
|
||||
|
||||
func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))]
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoRequestBodyType = map[string]any{}
|
||||
|
||||
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))]
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
type GoTypeAssociator struct{}
|
||||
|
||||
func NewGoTypeAssociator() *GoTypeAssociator {
|
||||
return &GoTypeAssociator{}
|
||||
}
|
||||
|
||||
func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) {
|
||||
return ManifestGoTypeAssociator(kind, version)
|
||||
}
|
||||
func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) {
|
||||
return ManifestCustomRouteResponsesAssociator(kind, version, path, verb)
|
||||
}
|
||||
func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) {
|
||||
return ManifestCustomRouteQueryAssociator(kind, version, path, verb)
|
||||
}
|
||||
func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) {
|
||||
return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana-app-sdk/operator"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
|
||||
unifiedStorage "github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
quotasv0alpha1 "github.com/grafana/grafana/apps/quotas/pkg/apis/quotas/v0alpha1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
type QuotasAppConfig struct {
|
||||
ResourceClient unifiedStorage.ResourceClient
|
||||
}
|
||||
|
||||
type QuotasHandler struct {
|
||||
ResourceClient unifiedStorage.ResourceClient
|
||||
}
|
||||
|
||||
func NewQuotasHandler(cfg *QuotasAppConfig) *QuotasHandler {
|
||||
return &QuotasHandler{
|
||||
ResourceClient: cfg.ResourceClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuota handles requests for the GET /usage resource route
|
||||
func (h *QuotasHandler) GetQuota(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
|
||||
if !request.URL.Query().Has("group") {
|
||||
// TODO its returning a 500 instead of 400 bad request
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return fmt.Errorf("missing required query parameters: group")
|
||||
}
|
||||
if !request.URL.Query().Has("resource") {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return fmt.Errorf("missing required query parameters: resource")
|
||||
}
|
||||
group := request.URL.Query().Get("group")
|
||||
res := request.URL.Query().Get("resource")
|
||||
|
||||
quotaReq := &resourcepb.QuotaUsageRequest{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: request.ResourceIdentifier.Namespace,
|
||||
Group: group,
|
||||
Resource: res,
|
||||
},
|
||||
}
|
||||
quota, err := h.ResourceClient.GetQuotaUsage(ctx, quotaReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(writer).Encode(quotasv0alpha1.GetUsage{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "quotas.grafana.com/v0alpha1",
|
||||
Kind: "Quotas",
|
||||
},
|
||||
GetUsageBody: quotasv0alpha1.GetUsageBody{
|
||||
Namespace: request.ResourceIdentifier.Namespace,
|
||||
Group: group,
|
||||
Resource: res,
|
||||
Usage: quota.Usage,
|
||||
Limit: quota.Limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func New(cfg app.Config) (app.App, error) {
|
||||
appConfig, ok := cfg.SpecificConfig.(*QuotasAppConfig)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected QuotasAppConfig but got %T", cfg.SpecificConfig)
|
||||
}
|
||||
handler := NewQuotasHandler(appConfig)
|
||||
|
||||
simpleConfig := simple.AppConfig{
|
||||
Name: "quotas",
|
||||
KubeConfig: cfg.KubeConfig,
|
||||
InformerConfig: simple.AppInformerConfig{
|
||||
InformerOptions: operator.InformerOptions{
|
||||
ErrorHandler: func(ctx context.Context, err error) {
|
||||
logging.FromContext(ctx).Error("Informer processing error", "error", err)
|
||||
},
|
||||
},
|
||||
},
|
||||
ManagedKinds: []simple.AppManagedKind{},
|
||||
VersionedCustomRoutes: map[string]simple.AppVersionRouteHandlers{
|
||||
"v0alpha1": {
|
||||
{
|
||||
Namespaced: true,
|
||||
Path: "usage",
|
||||
Method: "GET",
|
||||
}: handler.GetQuota,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
a, err := simple.NewApp(simpleConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = a.ValidateManifest(cfg.ManifestData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func GetKinds() map[schema.GroupVersion][]resource.Kind {
|
||||
return map[schema.GroupVersion][]resource.Kind{}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetQuota(t *testing.T) {
|
||||
t.Run("will return error when resource param is missing", func(t *testing.T) {
|
||||
clientMock := resource.NewMockResourceClient(t)
|
||||
handler := NewQuotasHandler(&QuotasAppConfig{
|
||||
ResourceClient: clientMock,
|
||||
})
|
||||
url, err := url.Parse("http://localhost:3000/apis/quotas.grafana.app/v0alpha1/namespaces/stacks-1/usage?group=dashboard.grafana.app")
|
||||
require.NoError(t, err)
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: url,
|
||||
Method: "GET",
|
||||
}
|
||||
recorder := &httptest.ResponseRecorder{}
|
||||
err = handler.GetQuota(context.Background(), recorder, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("will return error when group param is missing", func(t *testing.T) {
|
||||
clientMock := resource.NewMockResourceClient(t)
|
||||
handler := NewQuotasHandler(&QuotasAppConfig{
|
||||
ResourceClient: clientMock,
|
||||
})
|
||||
url, err := url.Parse("http://localhost:3000/apis/quotas.grafana.app/v0alpha1/namespaces/stacks-1/usage?resource=dashboards")
|
||||
require.NoError(t, err)
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: url,
|
||||
Method: "GET",
|
||||
}
|
||||
recorder := &httptest.ResponseRecorder{}
|
||||
err = handler.GetQuota(context.Background(), recorder, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("will return quotas response when params are valid", func(t *testing.T) {
|
||||
clientMock := resource.NewMockResourceClient(t)
|
||||
clientMock.On("GetQuotaUsage", mock.Anything, mock.Anything, mock.Anything).Return(&resourcepb.QuotaUsageResponse{
|
||||
Error: nil,
|
||||
Usage: 1,
|
||||
Limit: 2,
|
||||
}, nil)
|
||||
handler := NewQuotasHandler(&QuotasAppConfig{
|
||||
ResourceClient: clientMock,
|
||||
})
|
||||
url, err := url.Parse("http://localhost:3000/apis/quotas.grafana.app/v0alpha1/namespaces/stacks-1/usage?group=dashboard.grafana.app&resource=dashboards")
|
||||
require.NoError(t, err)
|
||||
req := &app.CustomRouteRequest{
|
||||
URL: url,
|
||||
Method: "GET",
|
||||
}
|
||||
recorder := &httptest.ResponseRecorder{}
|
||||
err = handler.GetQuota(context.Background(), recorder, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 200, recorder.Code)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user