From 3e572920334d03d8b481d95390a2a2a19e0df73f Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 21 Dec 2023 18:23:29 -0500 Subject: [PATCH] admin/back-office: implement project limits update endpoint Implement PUT method on back-office admin API endpoint /projects/limits/{publicID} to enable updating project max buckets, storage, bandwidth, segment, rate, and burst limits. Change-Id: I66ddbbf8dada4fdde4c3ac1953a271eedbc206b6 --- satellite/admin/back-office/api-docs.gen.md | 27 +++ satellite/admin/back-office/gen/main.go | 14 ++ satellite/admin/back-office/handlers.gen.go | 37 ++++ satellite/admin/back-office/projects.go | 57 ++++++ satellite/admin/back-office/projects_test.go | 176 ++++++++++++++++++ .../back-office/ui/src/api/client.gen.ts | 19 ++ 6 files changed, 330 insertions(+) diff --git a/satellite/admin/back-office/api-docs.gen.md b/satellite/admin/back-office/api-docs.gen.md index 82a3d7d866a2..1b5d0d6dd290 100644 --- a/satellite/admin/back-office/api-docs.gen.md +++ b/satellite/admin/back-office/api-docs.gen.md @@ -10,6 +10,7 @@ * [Get user](#usermanagement-get-user) * ProjectManagement * [Get project](#projectmanagement-get-project) + * [Update project limits](#projectmanagement-update-project-limits)

Get placements (go to full list)

@@ -113,3 +114,29 @@ Gets project by ID ``` +

Update project limits (go to full list)

+ +Updates project limits by ID + +`PUT /back-office/api/v1/projects/limits/{publicID}` + +**Path Params:** + +| name | type | elaboration | +|---|---|---| +| `publicID` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` | + +**Request body:** + +```typescript +{ + maxBuckets: number + storageLimit: number + bandwidthLimit: number + segmentLimit: number + rateLimit: number + burstLimit: number +} + +``` + diff --git a/satellite/admin/back-office/gen/main.go b/satellite/admin/back-office/gen/main.go index 67b353a15070..0e2059755bbf 100644 --- a/satellite/admin/back-office/gen/main.go +++ b/satellite/admin/back-office/gen/main.go @@ -71,6 +71,20 @@ func main() { }, }) + group.Put("/limits/{publicID}", &apigen.Endpoint{ + Name: "Update project limits", + Description: "Updates project limits by ID", + GoName: "UpdateProjectLimits", + TypeScriptName: "updateProjectLimits", + PathParams: []apigen.Param{ + apigen.NewParam("publicID", uuid.UUID{}), + }, + Request: backoffice.ProjectLimitsUpdate{}, + Settings: map[any]any{ + authPermsKey: []backoffice.Permission{backoffice.PermProjectSetLimits}, + }, + }) + modroot := findModuleRootDir() api.MustWriteGo(filepath.Join(modroot, "satellite", "admin", "back-office", "handlers.gen.go")) api.MustWriteTS(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client.gen.ts")) diff --git a/satellite/admin/back-office/handlers.gen.go b/satellite/admin/back-office/handlers.gen.go index f40d64caee55..d22a72b572c4 100644 --- a/satellite/admin/back-office/handlers.gen.go +++ b/satellite/admin/back-office/handlers.gen.go @@ -31,6 +31,7 @@ type UserManagementService interface { type ProjectManagementService interface { GetProject(ctx context.Context, publicID uuid.UUID) (*Project, api.HTTPError) + UpdateProjectLimits(ctx context.Context, publicID uuid.UUID, request ProjectLimitsUpdate) api.HTTPError } // PlacementManagementHandler is an api handler that implements all PlacementManagement API endpoints functionality. @@ -93,6 +94,7 @@ func NewProjectManagement(log *zap.Logger, mon *monkit.Scope, service ProjectMan projectsRouter := router.PathPrefix("/back-office/api/v1/projects").Subrouter() projectsRouter.HandleFunc("/{publicID}", handler.handleGetProject).Methods("GET") + projectsRouter.HandleFunc("/limits/{publicID}", handler.handleUpdateProjectLimits).Methods("PUT") return handler } @@ -179,3 +181,38 @@ func (h *ProjectManagementHandler) handleGetProject(w http.ResponseWriter, r *ht h.log.Debug("failed to write json GetProject response", zap.Error(ErrProjectsAPI.Wrap(err))) } } + +func (h *ProjectManagementHandler) handleUpdateProjectLimits(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer h.mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + publicIDParam, ok := mux.Vars(r)["publicID"] + if !ok { + api.ServeError(h.log, w, http.StatusBadRequest, errs.New("missing publicID route param")) + return + } + + publicID, err := uuid.FromString(publicIDParam) + if err != nil { + api.ServeError(h.log, w, http.StatusBadRequest, err) + return + } + + payload := ProjectLimitsUpdate{} + if err = json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.ServeError(h.log, w, http.StatusBadRequest, err) + return + } + + if h.auth.IsRejected(w, r, 16384) { + return + } + + httpErr := h.service.UpdateProjectLimits(ctx, publicID, payload) + if httpErr.Err != nil { + api.ServeError(h.log, w, httpErr.Status, httpErr.Err) + } +} diff --git a/satellite/admin/back-office/projects.go b/satellite/admin/back-office/projects.go index c74e602b63da..28a9239c1cc3 100644 --- a/satellite/admin/back-office/projects.go +++ b/satellite/admin/back-office/projects.go @@ -51,6 +51,16 @@ type ProjectUsageLimits[T ~int64 | *int64] struct { SegmentUsed *int64 `json:"segmentUsed"` } +// ProjectLimitsUpdate contains all limit values to be updated. +type ProjectLimitsUpdate struct { + MaxBuckets int `json:"maxBuckets"` + StorageLimit int64 `json:"storageLimit"` + BandwidthLimit int64 `json:"bandwidthLimit"` + SegmentLimit int64 `json:"segmentLimit"` + RateLimit int `json:"rateLimit"` + BurstLimit int `json:"burstLimit"` +} + // GetProject gets the project info. func (s *Service) GetProject(ctx context.Context, id uuid.UUID) (*Project, api.HTTPError) { var err error @@ -251,3 +261,50 @@ func (s *Service) getProjectUsage( return bandwidt, storage, segment, api.HTTPError{} } + +// UpdateProjectLimits updates the project's max buckets, storage, bandwidth, segment, rate, and burst limits. +func (s *Service) UpdateProjectLimits(ctx context.Context, id uuid.UUID, req ProjectLimitsUpdate) api.HTTPError { + var err error + defer mon.Task()(&ctx)(&err) + + p, err := s.consoleDB.Projects().GetByPublicID(ctx, id) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + return api.HTTPError{ + Status: status, + Err: Error.Wrap(err), + } + } + + buckets := &req.MaxBuckets + if req.MaxBuckets == s.defaults.MaxBuckets { + buckets = nil + } + rate := &req.RateLimit + if req.RateLimit == s.defaults.RateLimit { + rate = nil + } + burst := &req.BurstLimit + if req.BurstLimit == s.defaults.RateLimit { + burst = nil + } + + // Note: usage_limit (storage), bandwidth_limit, and segment_limit columns are also nullable, but are never actually null in production. + + err = s.consoleDB.Projects().UpdateAllLimits(ctx, p.ID, &req.StorageLimit, &req.BandwidthLimit, &req.SegmentLimit, buckets, rate, burst) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + return api.HTTPError{ + Status: status, + Err: Error.Wrap(err), + } + } + + return api.HTTPError{} +} diff --git a/satellite/admin/back-office/projects_test.go b/satellite/admin/back-office/projects_test.go index cd8345107633..702877535510 100644 --- a/satellite/admin/back-office/projects_test.go +++ b/satellite/admin/back-office/projects_test.go @@ -12,11 +12,13 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "storj.io/common/memory" "storj.io/common/pb" "storj.io/common/testcontext" "storj.io/common/testrand" "storj.io/storj/private/testplanet" "storj.io/storj/satellite" + admin "storj.io/storj/satellite/admin/back-office" "storj.io/storj/satellite/buckets" "storj.io/storj/satellite/console" "storj.io/storj/satellite/metabase" @@ -178,3 +180,177 @@ func TestGetProject(t *testing.T) { }) }) } + +func TestUpdateProjectLimits(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) { + config.LiveAccounting.AsOfSystemInterval = 0 + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + t.Run("unexisting project", func(t *testing.T) { + sat := planet.Satellites[0] + + service := sat.Admin.Admin.Service + apiErr := service.UpdateProjectLimits(ctx, testrand.UUID(), admin.ProjectLimitsUpdate{}) + require.Error(t, apiErr.Err) + assert.Equal(t, http.StatusNotFound, apiErr.Status) + }) + + t.Run("existing user", func(t *testing.T) { + consoleUser := &console.User{ + ID: testrand.UUID(), + FullName: "Test User", + Email: "test@storj.io", + PasswordHash: testrand.Bytes(8), + Status: console.Inactive, + UserAgent: []byte("agent"), + DefaultPlacement: 5, + } + + sat := planet.Satellites[0] + consoleDB := sat.DB.Console() + _, err := consoleDB.Users().Insert(ctx, consoleUser) + require.NoError(t, err) + + consoleUser.Status = console.Active + require.NoError( + t, + consoleDB.Users().Update(ctx, consoleUser.ID, console.UpdateUserRequest{Status: &consoleUser.Status}), + ) + + // Project with default limits + projID := testrand.UUID() + storage := memory.Size(1000) + bw := memory.Size(2000) + segment := int64(10000) + rate := 500 + burst := 200 + buckets := 500 + consoleProject := &console.Project{ + ID: projID, + Name: "project-free-account", + Description: "This is a project created at the time that owner's user account is a free account", + OwnerID: consoleUser.ID, + StorageLimit: &storage, + BandwidthLimit: &bw, + SegmentLimit: &segment, + RateLimit: &rate, + MaxBuckets: &buckets, + } + + consoleProject, err = consoleDB.Projects().Insert(ctx, consoleProject) + require.NoError(t, err) + // Insert doesn't set burst limit. + require.NoError(t, consoleDB.Projects().UpdateBurstLimit(ctx, projID, &burst)) + + projPublicID := consoleProject.PublicID + + service := sat.Admin.Admin.Service + project, err := sat.API.DB.Console().Projects().Get(ctx, projID) + require.NoError(t, err) + + require.NotNil(t, project.RateLimit) + assert.Equal(t, *consoleProject.RateLimit, *project.RateLimit) + require.NotNil(t, project.BurstLimit) + assert.Equal(t, burst, *project.BurstLimit) + require.NotNil(t, project.MaxBuckets) + assert.Equal(t, *consoleProject.MaxBuckets, *project.MaxBuckets) + + assert.EqualValues(t, consoleProject.BandwidthLimit, project.BandwidthLimit) + assert.EqualValues(t, consoleProject.StorageLimit, project.StorageLimit) + assert.EqualValues(t, consoleProject.SegmentLimit, project.SegmentLimit) + + // basic test + expectStorage := project.StorageLimit.Int64() * 2 + expectBandwidth := project.BandwidthLimit.Int64() * 2 + expectSegment := *project.SegmentLimit * 2 + expectBuckets := 100 + expectRate := 2000 + expectBurst := 500 + apiErr := service.UpdateProjectLimits(ctx, projPublicID, admin.ProjectLimitsUpdate{ + MaxBuckets: expectBuckets, + StorageLimit: expectStorage, + BandwidthLimit: expectBandwidth, + SegmentLimit: expectSegment, + RateLimit: expectRate, + BurstLimit: expectBurst, + }) + require.NoError(t, apiErr.Err) + + project, err = sat.API.DB.Console().Projects().Get(ctx, projID) + require.NoError(t, err) + + require.NotNil(t, project.MaxBuckets) + require.Equal(t, expectBuckets, *project.MaxBuckets) + require.NotNil(t, project.StorageLimit) + require.Equal(t, expectStorage, project.StorageLimit.Int64()) + require.NotNil(t, project.BandwidthLimit) + require.Equal(t, expectBandwidth, project.BandwidthLimit.Int64()) + require.NotNil(t, project.SegmentLimit) + require.Equal(t, expectSegment, *project.SegmentLimit) + require.NotNil(t, project.RateLimit) + require.Equal(t, expectRate, *project.RateLimit) + require.NotNil(t, project.BurstLimit) + require.Equal(t, expectBurst, *project.BurstLimit) + + // test updating max buckets, rate, and burst limits to default value sets null in DB. + // Storage, BW, and segment are still set explicitly even if default. + defaultStorage := sat.Config.Console.UsageLimits.Storage.Free.Int64() + defaultBandwidth := sat.Config.Console.UsageLimits.Bandwidth.Free.Int64() + defaultSegment := sat.Config.Console.UsageLimits.Segment.Free + + apiErr = service.UpdateProjectLimits(ctx, projPublicID, admin.ProjectLimitsUpdate{ + MaxBuckets: sat.Config.Metainfo.ProjectLimits.MaxBuckets, + RateLimit: int(sat.Config.Metainfo.RateLimiter.Rate), + BurstLimit: int(sat.Config.Metainfo.RateLimiter.Rate), + StorageLimit: defaultStorage, + BandwidthLimit: defaultBandwidth, + SegmentLimit: defaultSegment, + }) + require.NoError(t, apiErr.Err) + + project, err = sat.API.DB.Console().Projects().Get(ctx, projID) + require.NoError(t, err) + + require.Nil(t, project.MaxBuckets) + require.Nil(t, project.RateLimit) + require.Nil(t, project.BurstLimit) + require.NotNil(t, project.StorageLimit) + require.Equal(t, defaultStorage, project.StorageLimit.Int64()) + require.NotNil(t, project.BandwidthLimit) + require.Equal(t, defaultBandwidth, project.BandwidthLimit.Int64()) + require.NotNil(t, project.SegmentLimit) + require.Equal(t, defaultSegment, *project.SegmentLimit) + + // test setting to zero. + apiErr = service.UpdateProjectLimits(ctx, projPublicID, admin.ProjectLimitsUpdate{ + MaxBuckets: 0, + RateLimit: 0, + BurstLimit: 0, + StorageLimit: 0, + BandwidthLimit: 0, + SegmentLimit: 0, + }) + require.NoError(t, apiErr.Err) + + project, err = sat.API.DB.Console().Projects().Get(ctx, projID) + require.NoError(t, err) + + require.NotNil(t, project.MaxBuckets) + require.Zero(t, *project.MaxBuckets) + require.NotNil(t, project.RateLimit) + require.Zero(t, *project.RateLimit) + require.NotNil(t, project.BurstLimit) + require.Zero(t, *project.BurstLimit) + require.NotNil(t, project.StorageLimit) + require.Zero(t, *project.StorageLimit) + require.NotNil(t, project.BandwidthLimit) + require.Zero(t, *project.BandwidthLimit) + require.NotNil(t, project.SegmentLimit) + require.Zero(t, *project.SegmentLimit) + }) + }) +} diff --git a/satellite/admin/back-office/ui/src/api/client.gen.ts b/satellite/admin/back-office/ui/src/api/client.gen.ts index 47729387c857..a64b3d4889b5 100644 --- a/satellite/admin/back-office/ui/src/api/client.gen.ts +++ b/satellite/admin/back-office/ui/src/api/client.gen.ts @@ -28,6 +28,15 @@ export class Project { segmentUsed: number | null; } +export class ProjectLimitsUpdate { + maxBuckets: number; + storageLimit: number; + bandwidthLimit: number; + segmentLimit: number; + rateLimit: number; + burstLimit: number; +} + export class User { id: UUID; fullName: string; @@ -109,4 +118,14 @@ export class ProjectManagementHttpApiV1 { const err = await response.json(); throw new APIError(err.error, response.status); } + + public async updateProjectLimits(request: ProjectLimitsUpdate, publicID: UUID): Promise { + const fullPath = `${this.ROOT_PATH}/limits/${publicID}`; + const response = await this.http.put(fullPath, JSON.stringify(request)); + if (response.ok) { + return; + } + const err = await response.json(); + throw new APIError(err.error, response.status); + } }