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
+4
View File
@@ -39,6 +39,7 @@ type ResourceClient interface {
resourcepb.BulkStoreClient
resourcepb.BlobStoreClient
resourcepb.DiagnosticsClient
resourcepb.QuotasClient
}
// Internal implementation
@@ -49,6 +50,7 @@ type resourceClient struct {
resourcepb.BulkStoreClient
resourcepb.BlobStoreClient
resourcepb.DiagnosticsClient
resourcepb.QuotasClient
}
func NewResourceClient(conn, indexConn grpc.ClientConnInterface, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer trace.Tracer) (ResourceClient, error) {
@@ -76,6 +78,7 @@ func newResourceClient(storageCc grpc.ClientConnInterface, indexCc grpc.ClientCo
BulkStoreClient: resourcepb.NewBulkStoreClient(storageCc),
BlobStoreClient: resourcepb.NewBlobStoreClient(storageCc),
DiagnosticsClient: resourcepb.NewDiagnosticsClient(storageCc),
QuotasClient: resourcepb.NewQuotasClient(storageCc),
}
}
@@ -102,6 +105,7 @@ func NewLocalResourceClient(server ResourceServer) ResourceClient {
&resourcepb.BlobStore_ServiceDesc,
&resourcepb.BulkStore_ServiceDesc,
&resourcepb.Diagnostics_ServiceDesc,
&resourcepb.Quotas_ServiceDesc,
} {
channel.RegisterService(
grpchan.InterceptServer(
@@ -394,6 +394,80 @@ func (_c *MockResourceClient_GetBlob_Call) RunAndReturn(run func(context.Context
return _c
}
// GetQuotaUsage provides a mock function with given fields: ctx, in, opts
func (_m *MockResourceClient) GetQuotaUsage(ctx context.Context, in *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error) {
_va := make([]interface{}, len(opts))
for _i := range opts {
_va[_i] = opts[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx, in)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for GetQuotaUsage")
}
var r0 *resourcepb.QuotaUsageResponse
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *resourcepb.QuotaUsageRequest, ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error)); ok {
return rf(ctx, in, opts...)
}
if rf, ok := ret.Get(0).(func(context.Context, *resourcepb.QuotaUsageRequest, ...grpc.CallOption) *resourcepb.QuotaUsageResponse); ok {
r0 = rf(ctx, in, opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*resourcepb.QuotaUsageResponse)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *resourcepb.QuotaUsageRequest, ...grpc.CallOption) error); ok {
r1 = rf(ctx, in, opts...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockResourceClient_GetQuotaUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetQuotaUsage'
type MockResourceClient_GetQuotaUsage_Call struct {
*mock.Call
}
// GetQuotaUsage is a helper method to define mock.On call
// - ctx context.Context
// - in *resourcepb.QuotaUsageRequest
// - opts ...grpc.CallOption
func (_e *MockResourceClient_Expecter) GetQuotaUsage(ctx interface{}, in interface{}, opts ...interface{}) *MockResourceClient_GetQuotaUsage_Call {
return &MockResourceClient_GetQuotaUsage_Call{Call: _e.mock.On("GetQuotaUsage",
append([]interface{}{ctx, in}, opts...)...)}
}
func (_c *MockResourceClient_GetQuotaUsage_Call) Run(run func(ctx context.Context, in *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption)) *MockResourceClient_GetQuotaUsage_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]grpc.CallOption, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(grpc.CallOption)
}
}
run(args[0].(context.Context), args[1].(*resourcepb.QuotaUsageRequest), variadicArgs...)
})
return _c
}
func (_c *MockResourceClient_GetQuotaUsage_Call) Return(_a0 *resourcepb.QuotaUsageResponse, _a1 error) *MockResourceClient_GetQuotaUsage_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockResourceClient_GetQuotaUsage_Call) RunAndReturn(run func(context.Context, *resourcepb.QuotaUsageRequest, ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error)) *MockResourceClient_GetQuotaUsage_Call {
_c.Call.Return(run)
return _c
}
// GetStats provides a mock function with given fields: ctx, in, opts
func (_m *MockResourceClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
_va := make([]interface{}, len(opts))
+33
View File
@@ -41,6 +41,7 @@ type ResourceServer interface {
resourcepb.ManagedObjectIndexServer
resourcepb.BlobStoreServer
resourcepb.DiagnosticsServer
resourcepb.QuotasServer
}
type ListIterator interface {
@@ -1466,6 +1467,38 @@ func (s *server) PutBlob(ctx context.Context, req *resourcepb.PutBlobRequest) (*
return rsp, nil
}
func (s *server) GetQuotaUsage(ctx context.Context, req *resourcepb.QuotaUsageRequest) (*resourcepb.QuotaUsageResponse, error) {
if s.overridesService == nil {
return &resourcepb.QuotaUsageResponse{Error: &resourcepb.ErrorResult{
Message: "overrides service not configured on resource server",
Code: http.StatusNotImplemented,
}}, nil
}
nsr := NamespacedResource{
Namespace: req.Key.Namespace,
Group: req.Key.Group,
Resource: req.Key.Resource,
}
usage, err := s.backend.GetResourceStats(ctx, nsr, 0)
if err != nil {
return &resourcepb.QuotaUsageResponse{Error: AsErrorResult(err)}, nil
}
limit, err := s.overridesService.GetQuota(ctx, nsr)
if err != nil {
return &resourcepb.QuotaUsageResponse{Error: AsErrorResult(err)}, nil
}
// handle case where no resources exist yet - very unlikely but possible
rsp := &resourcepb.QuotaUsageResponse{Limit: int64(limit.Limit)}
if len(usage) <= 0 {
rsp.Usage = 0
} else {
rsp.Usage = usage[0].Count
}
return rsp, nil
}
func (s *server) getPartialObject(ctx context.Context, key *resourcepb.ResourceKey, rv int64) (utils.GrafanaMetaAccessor, *resourcepb.ErrorResult) {
if r := verifyRequestKey(key); r != nil {
return nil, r
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -22,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util/scheduler"
)
@@ -614,3 +617,70 @@ func TestArtificialDelayAfterSuccessfulOperation(t *testing.T) {
check(t, false, &resourcepb.UpdateResponse{Error: AsErrorResult(errors.New("some error"))}, nil)
check(t, false, &resourcepb.DeleteResponse{Error: AsErrorResult(errors.New("some error"))}, nil)
}
func TestGetQuotaUsage(t *testing.T) {
ctx := context.Background()
t.Run("returns error when overrides service is not configured", func(t *testing.T) {
s := &server{
overridesService: nil,
log: log.NewNopLogger(),
}
resp, err := s.GetQuotaUsage(ctx, &resourcepb.QuotaUsageRequest{
Key: &resourcepb.ResourceKey{
Namespace: "stacks-123",
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
})
require.NoError(t, err)
require.NotNil(t, resp.Error)
assert.Equal(t, int32(http.StatusNotImplemented), resp.Error.Code)
assert.Equal(t, "overrides service not configured on resource server", resp.Error.Message)
})
t.Run("returns usage and limit successfully", func(t *testing.T) {
// Create a temporary overrides config file
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
dashboard.grafana.app/dashboards:
limit: 500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
// Create a real OverridesService with the temp file
overridesService, err := NewOverridesService(ctx, log.NewNopLogger(), prometheus.NewRegistry(), tracing.NewNoopTracerService(), ReloadOptions{
FilePath: tmpFile,
})
require.NoError(t, err)
require.NoError(t, overridesService.init(ctx))
defer func() {
_ = overridesService.stop(ctx)
}()
// Create a mock backend that returns resource stats (reusing mockStorageBackend from search_test.go)
mockBackend := &mockStorageBackend{
resourceStats: []ResourceStats{{Count: 42}},
}
s := &server{
backend: mockBackend,
overridesService: overridesService,
log: log.NewNopLogger(),
}
resp, err := s.GetQuotaUsage(ctx, &resourcepb.QuotaUsageRequest{
Key: &resourcepb.ResourceKey{
Namespace: "stacks-123",
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
})
require.NoError(t, err)
require.Nil(t, resp.Error)
assert.Equal(t, int64(42), resp.Usage)
assert.Equal(t, int64(500), resp.Limit)
})
}