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:
owensmallwood
2025-12-09 09:40:34 -06:00
committed by GitHub
co-authored by Steve Simpson
parent 297e886e1b
commit a3daf0e39d
47 changed files with 1758 additions and 149 deletions
+123
View File
@@ -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{}
}
+71
View File
@@ -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)
})
}