diff --git a/cmd/satellite/admin.go b/cmd/satellite/admin.go index 072c74067..56e092372 100644 --- a/cmd/satellite/admin.go +++ b/cmd/satellite/admin.go @@ -11,6 +11,8 @@ import ( "storj.io/private/process" "storj.io/private/version" "storj.io/storj/satellite" + "storj.io/storj/satellite/accounting" + "storj.io/storj/satellite/accounting/live" "storj.io/storj/satellite/metabase" "storj.io/storj/satellite/satellitedb" ) @@ -47,7 +49,21 @@ func cmdAdminRun(cmd *cobra.Command, args []string) (err error) { err = errs.Combine(err, metabaseDB.Close()) }() - peer, err := satellite.NewAdmin(log, identity, db, metabaseDB, version.Build, &runCfg.Config, process.AtomicLevel(cmd)) + accountingCache, err := live.OpenCache(ctx, log.Named("live-accounting"), runCfg.LiveAccounting) + if err != nil { + if !accounting.ErrSystemOrNetError.Has(err) || accountingCache == nil { + return errs.New("Error instantiating live accounting cache: %w", err) + } + + log.Warn("Unable to connect to live accounting cache. Verify connection", + zap.Error(err), + ) + } + defer func() { + err = errs.Combine(err, accountingCache.Close()) + }() + + peer, err := satellite.NewAdmin(log, identity, db, metabaseDB, accountingCache, version.Build, &runCfg.Config, process.AtomicLevel(cmd)) if err != nil { return err } diff --git a/private/testplanet/satellite.go b/private/testplanet/satellite.go index 041c69a82..d31a50ccc 100644 --- a/private/testplanet/satellite.go +++ b/private/testplanet/satellite.go @@ -695,7 +695,13 @@ func (planet *Planet) newAdmin(ctx context.Context, index int, identity *identit prefix := "satellite-admin" + strconv.Itoa(index) log := planet.log.Named(prefix) - return satellite.NewAdmin(log, identity, db, metabaseDB, versionInfo, &config, nil) + liveAccounting, err := live.OpenCache(ctx, log.Named("live-accounting"), config.LiveAccounting) + if err != nil { + return nil, errs.Wrap(err) + } + planet.databases = append(planet.databases, liveAccounting) + + return satellite.NewAdmin(log, identity, db, metabaseDB, liveAccounting, versionInfo, &config, nil) } func (planet *Planet) newRepairer(ctx context.Context, index int, identity *identity.FullIdentity, db satellite.DB, metabaseDB *metabase.DB, config satellite.Config, versionInfo version.Info) (_ *satellite.Repairer, err error) { diff --git a/satellite/admin.go b/satellite/admin.go index 443f1d7a2..4157d65d4 100644 --- a/satellite/admin.go +++ b/satellite/admin.go @@ -20,7 +20,9 @@ import ( "storj.io/private/version" "storj.io/storj/private/lifecycle" "storj.io/storj/private/version/checker" + "storj.io/storj/satellite/accounting" "storj.io/storj/satellite/admin" + backoffice "storj.io/storj/satellite/admin/back-office" "storj.io/storj/satellite/analytics" "storj.io/storj/satellite/buckets" "storj.io/storj/satellite/console" @@ -66,6 +68,7 @@ type Admin struct { Admin struct { Listener net.Listener Server *admin.Server + Service *backoffice.Service } Buckets struct { @@ -79,11 +82,23 @@ type Admin struct { FreezeAccounts struct { Service *console.AccountFreezeService } + + LiveAccounting struct { + Cache accounting.Cache + } + + ProjectLimits struct { + Cache *accounting.ProjectLimitCache + } + + Accounting struct { + Service *accounting.Service + } } // NewAdmin creates a new satellite admin peer. func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *metabase.DB, - versionInfo version.Info, config *Config, atomicLogLevel *zap.AtomicLevel) (*Admin, error) { + liveAccounting accounting.Cache, versionInfo version.Info, config *Config, atomicLogLevel *zap.AtomicLevel) (*Admin, error) { peer := &Admin{ Log: log, Identity: full, @@ -208,7 +223,31 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m peer.Payments.Accounts = peer.Payments.Service.Accounts() } - { // setup admin endpoint + { // setup live accounting + peer.LiveAccounting.Cache = liveAccounting + } + + { // setup project limits + peer.ProjectLimits.Cache = accounting.NewProjectLimitCache(peer.DB.ProjectAccounting(), + config.Console.Config.UsageLimits.Storage.Free, + config.Console.Config.UsageLimits.Bandwidth.Free, + config.Console.Config.UsageLimits.Segment.Free, + config.ProjectLimit, + ) + } + + { // setup accounting project usage + peer.Accounting.Service = accounting.NewService( + peer.DB.ProjectAccounting(), + peer.LiveAccounting.Cache, + peer.ProjectLimits.Cache, + *metabaseDB, + config.LiveAccounting.BandwidthCacheTTL, + config.LiveAccounting.AsOfSystemInterval, + ) + } + + { // setup admin var err error peer.Admin.Listener, err = net.Listen("tcp", config.Admin.Address) if err != nil { @@ -220,6 +259,14 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m return nil, err } + peer.Admin.Service = backoffice.NewService( + log.Named("back-office:service"), + peer.DB.Console(), + peer.DB.ProjectAccounting(), + peer.Accounting.Service, + placement, + ) + adminConfig := config.Admin adminConfig.AuthorizationToken = config.Console.AuthToken @@ -232,7 +279,7 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m peer.FreezeAccounts.Service, peer.Analytics.Service, peer.Payments.Accounts, - placement, + peer.Admin.Service, config.Console, adminConfig, ) diff --git a/satellite/admin/back-office/api-docs.gen.md b/satellite/admin/back-office/api-docs.gen.md index 58577cf4b..2c8566ceb 100644 --- a/satellite/admin/back-office/api-docs.gen.md +++ b/satellite/admin/back-office/api-docs.gen.md @@ -6,6 +6,8 @@ * PlacementManagement * [Get placements](#placementmanagement-get-placements) +* UserManagement + * [Get user](#usermanagement-get-user)

Get placements (go to full list)

@@ -26,3 +28,45 @@ Gets placement rule IDs and their locations ``` +

Get user (go to full list)

+ +Gets user by email address + +`GET /back-office/api/v1/users/{email}` + +**Path Params:** + +| name | type | elaboration | +|---|---|---| +| `email` | `string` | | + +**Response body:** + +```typescript +{ + id: string // UUID formatted as `00000000-0000-0000-0000-000000000000` + fullName: string + email: string + paidTier: boolean + createdAt: string // Date timestamp formatted as `2006-01-02T15:00:00Z` + status: string + userAgent: string + defaultPlacement: number + projectUsageLimits: [ + { + id: string // UUID formatted as `00000000-0000-0000-0000-000000000000` + name: string + storageLimit: number + storageUsed: number + bandwidthLimit: number + bandwidthUsed: number + segmentLimit: number + segmentUsed: number + } + + ] + +} + +``` + diff --git a/satellite/admin/back-office/gen/main.go b/satellite/admin/back-office/gen/main.go index ce3e0d25a..dbeb41bfa 100644 --- a/satellite/admin/back-office/gen/main.go +++ b/satellite/admin/back-office/gen/main.go @@ -34,6 +34,19 @@ func main() { Response: []backoffice.PlacementInfo{}, }) + group = api.Group("UserManagement", "users") + + group.Get("/{email}", &apigen.Endpoint{ + Name: "Get user", + Description: "Gets user by email address", + GoName: "GetUserByEmail", + TypeScriptName: "getUserByEmail", + PathParams: []apigen.Param{ + apigen.NewParam("email", ""), + }, + Response: backoffice.User{}, + }) + 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 53affda77..ec286af73 100644 --- a/satellite/admin/back-office/handlers.gen.go +++ b/satellite/admin/back-office/handlers.gen.go @@ -17,11 +17,16 @@ import ( ) var ErrPlacementsAPI = errs.Class("admin placements api") +var ErrUsersAPI = errs.Class("admin users api") type PlacementManagementService interface { GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) } +type UserManagementService interface { + GetUserByEmail(ctx context.Context, email string) (*User, api.HTTPError) +} + // PlacementManagementHandler is an api handler that implements all PlacementManagement API endpoints functionality. type PlacementManagementHandler struct { log *zap.Logger @@ -29,6 +34,13 @@ type PlacementManagementHandler struct { service PlacementManagementService } +// UserManagementHandler is an api handler that implements all UserManagement API endpoints functionality. +type UserManagementHandler struct { + log *zap.Logger + mon *monkit.Scope + service UserManagementService +} + func NewPlacementManagement(log *zap.Logger, mon *monkit.Scope, service PlacementManagementService, router *mux.Router) *PlacementManagementHandler { handler := &PlacementManagementHandler{ log: log, @@ -42,6 +54,19 @@ func NewPlacementManagement(log *zap.Logger, mon *monkit.Scope, service Placemen return handler } +func NewUserManagement(log *zap.Logger, mon *monkit.Scope, service UserManagementService, router *mux.Router) *UserManagementHandler { + handler := &UserManagementHandler{ + log: log, + mon: mon, + service: service, + } + + usersRouter := router.PathPrefix("/back-office/api/v1/users").Subrouter() + usersRouter.HandleFunc("/{email}", handler.handleGetUserByEmail).Methods("GET") + + return handler +} + func (h *PlacementManagementHandler) handleGetPlacements(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var err error @@ -60,3 +85,28 @@ func (h *PlacementManagementHandler) handleGetPlacements(w http.ResponseWriter, h.log.Debug("failed to write json GetPlacements response", zap.Error(ErrPlacementsAPI.Wrap(err))) } } + +func (h *UserManagementHandler) handleGetUserByEmail(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") + + email, ok := mux.Vars(r)["email"] + if !ok { + api.ServeError(h.log, w, http.StatusBadRequest, errs.New("missing email route param")) + return + } + + retVal, httpErr := h.service.GetUserByEmail(ctx, email) + if httpErr.Err != nil { + api.ServeError(h.log, w, httpErr.Status, httpErr.Err) + return + } + + err = json.NewEncoder(w).Encode(retVal) + if err != nil { + h.log.Debug("failed to write json GetUserByEmail response", zap.Error(ErrUsersAPI.Wrap(err))) + } +} diff --git a/satellite/admin/back-office/placements.go b/satellite/admin/back-office/placements.go index a846734da..324886ec4 100644 --- a/satellite/admin/back-office/placements.go +++ b/satellite/admin/back-office/placements.go @@ -18,7 +18,7 @@ type PlacementInfo struct { } // GetPlacements returns IDs and locations of placement rules. -func (s *Server) GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) { +func (s *Service) GetPlacements(ctx context.Context) ([]PlacementInfo, api.HTTPError) { var err error defer mon.Task()(&ctx)(&err) diff --git a/satellite/admin/back-office/server.go b/satellite/admin/back-office/server.go index 7512eb72d..b088f4ef9 100644 --- a/satellite/admin/back-office/server.go +++ b/satellite/admin/back-office/server.go @@ -17,7 +17,6 @@ import ( "storj.io/common/errs2" ui "storj.io/storj/satellite/admin/back-office/ui" - "storj.io/storj/satellite/overlay" ) // PathPrefix is the path that will be prefixed to the router passed to the NewServer constructor. @@ -44,9 +43,8 @@ type Config struct { // Server serves the API endpoints and the web application to allow preforming satellite // administration tasks. type Server struct { - log *zap.Logger - listener net.Listener - placement *overlay.PlacementDefinitions + log *zap.Logger + listener net.Listener config Config @@ -60,15 +58,14 @@ type Server struct { func NewServer( log *zap.Logger, listener net.Listener, - placement *overlay.PlacementDefinitions, + service *Service, root *mux.Router, config Config, ) *Server { server := &Server{ - log: log, - listener: listener, - placement: placement, - config: config, + log: log, + listener: listener, + config: config, } if root == nil { @@ -77,7 +74,8 @@ func NewServer( // API endpoints. // API generator already add the PathPrefix. - NewPlacementManagement(log, mon, server, root) + NewPlacementManagement(log, mon, service, root) + NewUserManagement(log, mon, service, root) root = root.PathPrefix(PathPrefix).Subrouter() // Static assets for the web interface. diff --git a/satellite/admin/back-office/service.go b/satellite/admin/back-office/service.go new file mode 100644 index 000000000..f8061d602 --- /dev/null +++ b/satellite/admin/back-office/service.go @@ -0,0 +1,38 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin + +import ( + "go.uber.org/zap" + + "storj.io/storj/satellite/accounting" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/overlay" +) + +// Service provides functionality for administrating satellites. +type Service struct { + log *zap.Logger + consoleDB console.DB + accountingDB accounting.ProjectAccounting + accounting *accounting.Service + placement *overlay.PlacementDefinitions +} + +// NewService creates a new satellite administration service. +func NewService( + log *zap.Logger, + consoleDB console.DB, + accountingDB accounting.ProjectAccounting, + accounting *accounting.Service, + placement *overlay.PlacementDefinitions, +) *Service { + return &Service{ + log: log, + consoleDB: consoleDB, + accountingDB: accountingDB, + accounting: accounting, + placement: placement, + } +} 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 825f15e57..9bff6bbcf 100644 --- a/satellite/admin/back-office/ui/src/api/client.gen.ts +++ b/satellite/admin/back-office/ui/src/api/client.gen.ts @@ -2,12 +2,36 @@ // DO NOT EDIT. import { HttpClient } from '@/utils/httpClient'; +import { Time, UUID } from '@/types/common'; export class PlacementInfo { id: number; location: string; } +export class ProjectUsageLimits { + id: UUID; + name: string; + storageLimit: number; + storageUsed: number | null; + bandwidthLimit: number; + bandwidthUsed: number; + segmentLimit: number; + segmentUsed: number | null; +} + +export class User { + id: UUID; + fullName: string; + email: string; + paidTier: boolean; + createdAt: Time; + status: string; + userAgent: string; + defaultPlacement: number; + projectUsageLimits: ProjectUsageLimits[] | null; +} + class APIError extends Error { constructor( public readonly msg: string, @@ -31,3 +55,18 @@ export class PlacementManagementHttpApiV1 { throw new APIError(err.error, response.status); } } + +export class UserManagementHttpApiV1 { + private readonly http: HttpClient = new HttpClient(); + private readonly ROOT_PATH: string = '/back-office/api/v1/users'; + + public async getUserByEmail(email: string): Promise { + const fullPath = `${this.ROOT_PATH}/${email}`; + const response = await this.http.get(fullPath); + if (response.ok) { + return response.json().then((body) => body as User); + } + const err = await response.json(); + throw new APIError(err.error, response.status); + } +} diff --git a/satellite/admin/back-office/ui/src/types/common.ts b/satellite/admin/back-office/ui/src/types/common.ts new file mode 100644 index 000000000..8bc24d0cd --- /dev/null +++ b/satellite/admin/back-office/ui/src/types/common.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +// TODO: fully implement these types and their methods according to their Go counterparts +export type UUID = string; +export type MemorySize = string; +export type Time = string; diff --git a/satellite/admin/back-office/users.go b/satellite/admin/back-office/users.go new file mode 100644 index 000000000..a0060cac6 --- /dev/null +++ b/satellite/admin/back-office/users.go @@ -0,0 +1,159 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin + +import ( + "context" + "database/sql" + "errors" + "net/http" + "time" + + "go.uber.org/zap" + + "storj.io/common/storj" + "storj.io/common/uuid" + "storj.io/storj/private/api" + "storj.io/storj/satellite/accounting" +) + +// User holds information about a user account. +type User struct { + ID uuid.UUID `json:"id"` + FullName string `json:"fullName"` + Email string `json:"email"` + PaidTier bool `json:"paidTier"` + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` + UserAgent string `json:"userAgent"` + DefaultPlacement storj.PlacementConstraint `json:"defaultPlacement"` + ProjectUsageLimits []ProjectUsageLimits `json:"projectUsageLimits"` +} + +// ProjectUsageLimits holds project usage limits and current usage. +// StorageUsed, BandwidthUsed, and SegmentUsed are nil if there was +// an error connecting to the Redis live accounting cache. +type ProjectUsageLimits struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + StorageLimit int64 `json:"storageLimit"` + StorageUsed *int64 `json:"storageUsed"` + BandwidthLimit int64 `json:"bandwidthLimit"` + BandwidthUsed int64 `json:"bandwidthUsed"` + SegmentLimit int64 `json:"segmentLimit"` + SegmentUsed *int64 `json:"segmentUsed"` +} + +// GetUserByEmail returns a verified user by its email address. +func (s *Service) GetUserByEmail(ctx context.Context, email string) (*User, api.HTTPError) { + var err error + defer mon.Task()(&ctx)(&err) + + user, err := s.consoleDB.Users().GetByEmail(ctx, email) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + return nil, api.HTTPError{ + Status: status, + Err: Error.Wrap(err), + } + } + + projects, err := s.consoleDB.Projects().GetOwn(ctx, user.ID) + if err != nil { + return nil, api.HTTPError{ + Status: http.StatusInternalServerError, + Err: Error.Wrap(err), + } + } + + // We return status 409 in the rare case that a project is deleted + // before its limits can be obtained. + makeDBErr := func(err error) api.HTTPError { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusConflict + } + return api.HTTPError{ + Status: status, + Err: Error.Wrap(err), + } + } + + var cacheErrs []error + usageLimits := make([]ProjectUsageLimits, 0, len(projects)) + for _, project := range projects { + usage := ProjectUsageLimits{ + ID: project.ID, + Name: project.Name, + } + + storageLimit, err := s.accounting.GetProjectStorageLimit(ctx, project.ID) + if err != nil { + return nil, makeDBErr(err) + } + usage.StorageLimit = storageLimit.Int64() + + bandwidthLimit, err := s.accounting.GetProjectBandwidthLimit(ctx, project.ID) + if err != nil { + return nil, makeDBErr(err) + } + usage.BandwidthLimit = bandwidthLimit.Int64() + + segmentLimit, err := s.accounting.GetProjectSegmentLimit(ctx, project.ID) + if err != nil { + return nil, makeDBErr(err) + } + usage.SegmentLimit = segmentLimit.Int64() + + storageUsed, err := s.accounting.GetProjectStorageTotals(ctx, project.ID) + if err == nil { + usage.StorageUsed = &storageUsed + } else if accounting.ErrSystemOrNetError.Has(err) { + cacheErrs = append(cacheErrs, err) + } else { + return nil, api.HTTPError{ + Status: http.StatusInternalServerError, + Err: Error.Wrap(err), + } + } + + usage.BandwidthUsed, err = s.accounting.GetProjectBandwidthTotals(ctx, project.ID) + if err != nil { + return nil, makeDBErr(err) + } + + segmentUsed, err := s.accounting.GetProjectSegmentTotals(ctx, project.ID) + if err == nil { + usage.SegmentUsed = &segmentUsed + } else if accounting.ErrSystemOrNetError.Has(err) { + cacheErrs = append(cacheErrs, err) + } else { + return nil, api.HTTPError{ + Status: http.StatusInternalServerError, + Err: Error.Wrap(err), + } + } + + usageLimits = append(usageLimits, usage) + } + + if len(cacheErrs) != 0 { + s.log.Warn("Error getting project usage data from live accounting cache", zap.Errors("errors", cacheErrs)) + } + + return &User{ + ID: user.ID, + FullName: user.FullName, + Email: user.Email, + PaidTier: user.PaidTier, + CreatedAt: user.CreatedAt, + Status: user.Status.String(), + UserAgent: string(user.UserAgent), + DefaultPlacement: user.DefaultPlacement, + ProjectUsageLimits: usageLimits, + }, api.HTTPError{} +} diff --git a/satellite/admin/back-office/users_test.go b/satellite/admin/back-office/users_test.go new file mode 100644 index 000000000..3babd9de3 --- /dev/null +++ b/satellite/admin/back-office/users_test.go @@ -0,0 +1,168 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin_test + +import ( + "fmt" + "net/http" + "sort" + "testing" + "time" + + "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" + "storj.io/storj/satellite/buckets" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/metabase" + "storj.io/storj/satellite/metabase/metabasetest" +) + +func TestGetUser(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index int, config *satellite.Config) { + config.LiveAccounting.AsOfSystemInterval = 0 + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + service := sat.Admin.Admin.Service + consoleDB := sat.DB.Console() + + 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, + } + + _, apiErr := service.GetUserByEmail(ctx, consoleUser.Email) + require.Equal(t, http.StatusNotFound, apiErr.Status) + require.Error(t, apiErr.Err) + + _, err := consoleDB.Users().Insert(ctx, consoleUser) + require.NoError(t, err) + + consoleUser.PaidTier = true + require.NoError(t, sat.DB.Console().Users().Update(ctx, consoleUser.ID, console.UpdateUserRequest{PaidTier: &consoleUser.PaidTier})) + + _, apiErr = service.GetUserByEmail(ctx, consoleUser.Email) + require.Equal(t, http.StatusNotFound, apiErr.Status) + require.Error(t, apiErr.Err) + + consoleUser.Status = console.Active + require.NoError(t, consoleDB.Users().Update(ctx, consoleUser.ID, console.UpdateUserRequest{Status: &consoleUser.Status})) + + user, apiErr := service.GetUserByEmail(ctx, consoleUser.Email) + require.NoError(t, apiErr.Err) + require.NotNil(t, user) + require.Equal(t, consoleUser.ID, user.ID) + require.Equal(t, consoleUser.FullName, user.FullName) + require.Equal(t, consoleUser.Email, user.Email) + require.Equal(t, consoleUser.PaidTier, user.PaidTier) + require.Equal(t, consoleUser.Status.String(), user.Status) + require.Equal(t, string(consoleUser.UserAgent), user.UserAgent) + require.Equal(t, consoleUser.DefaultPlacement, user.DefaultPlacement) + require.Empty(t, user.ProjectUsageLimits) + + type expectedTotal struct { + storage int64 + segments int64 + bandwidth int64 + objects int64 + } + + var projects []*console.Project + var expectedTotals []expectedTotal + + for projNum := 1; projNum <= 2; projNum++ { + storageLimit := memory.GB * memory.Size(projNum) + bandwidthLimit := memory.GB * memory.Size(projNum*2) + segmentLimit := int64(1000 * projNum) + + proj := &console.Project{ + ID: testrand.UUID(), + Name: fmt.Sprintf("Project %d", projNum), + OwnerID: consoleUser.ID, + StorageLimit: &storageLimit, + BandwidthLimit: &bandwidthLimit, + SegmentLimit: &segmentLimit, + } + projects = append(projects, proj) + + _, err := consoleDB.Projects().Insert(ctx, proj) + require.NoError(t, err) + + _, err = consoleDB.ProjectMembers().Insert(ctx, user.ID, proj.ID) + require.NoError(t, err) + + bucket, err := sat.DB.Buckets().CreateBucket(ctx, buckets.Bucket{ + ID: testrand.UUID(), + Name: testrand.BucketName(), + ProjectID: proj.ID, + }) + require.NoError(t, err) + + total := expectedTotal{} + + for objNum := 0; objNum < projNum*10; objNum++ { + obj := metabasetest.CreateObject(ctx, t, sat.Metabase.DB, metabase.ObjectStream{ + ProjectID: proj.ID, + BucketName: bucket.Name, + ObjectKey: metabasetest.RandObjectKey(), + Version: 12345, + StreamID: testrand.UUID(), + }, byte(16*projNum)) + + total.storage += obj.TotalEncryptedSize + total.segments += int64(obj.SegmentCount) + total.objects++ + } + + testBandwidth := int64(2000 * projNum) + err = sat.DB.Orders().UpdateBucketBandwidthAllocation(ctx, proj.ID, []byte(bucket.Name), pb.PieceAction_GET, testBandwidth, time.Now()) + require.NoError(t, err) + total.bandwidth += testBandwidth + + expectedTotals = append(expectedTotals, total) + } + + sat.Accounting.Tally.Loop.TriggerWait() + + user, apiErr = service.GetUserByEmail(ctx, consoleUser.Email) + require.NoError(t, apiErr.Err) + require.NotNil(t, user) + require.Len(t, user.ProjectUsageLimits, len(projects)) + + sort.Slice(user.ProjectUsageLimits, func(i, j int) bool { + return user.ProjectUsageLimits[i].Name < user.ProjectUsageLimits[j].Name + }) + + for i, info := range user.ProjectUsageLimits { + proj := projects[i] + name := proj.Name + require.Equal(t, proj.ID, info.ID, name) + require.Equal(t, name, info.Name, name) + require.EqualValues(t, *proj.StorageLimit, info.StorageLimit, name) + require.EqualValues(t, *proj.BandwidthLimit, info.BandwidthLimit, name) + require.Equal(t, *proj.SegmentLimit, info.SegmentLimit, name) + + total := expectedTotals[i] + require.Equal(t, total.storage, *info.StorageUsed, name) + require.Equal(t, total.bandwidth, info.BandwidthUsed, name) + require.Equal(t, total.segments, *info.SegmentUsed, name) + } + }) +} diff --git a/satellite/admin/server.go b/satellite/admin/server.go index 04bbb571d..227cce003 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -29,7 +29,6 @@ import ( "storj.io/storj/satellite/console/consoleweb" "storj.io/storj/satellite/console/restkeys" "storj.io/storj/satellite/oidc" - "storj.io/storj/satellite/overlay" "storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments/stripe" ) @@ -106,7 +105,7 @@ func NewServer( freezeAccounts *console.AccountFreezeService, analyticsService *analytics.Service, accounts payments.Accounts, - placement *overlay.PlacementDefinitions, + backOfficeService *backoffice.Service, console consoleweb.Config, config Config, ) *Server { @@ -185,7 +184,13 @@ func NewServer( // NewServer adds the backoffice.PahtPrefix for the static assets, but not for the API because the // generator already add the PathPrefix to router when the API handlers are hooked. - _ = backoffice.NewServer(log.Named("back-office"), nil, placement, root, config.BackOffice) + _ = backoffice.NewServer( + log.Named("back-office"), + nil, + backOfficeService, + root, + config.BackOffice, + ) // This handler must be the last one because it uses the root as prefix, // otherwise will try to serve all the handlers set after this one. diff --git a/satellite/console/users.go b/satellite/console/users.go index a13416a17..6e54265e3 100644 --- a/satellite/console/users.go +++ b/satellite/console/users.go @@ -174,6 +174,24 @@ const ( LegalHold UserStatus = 4 ) +// String returns a string representation of the user status. +func (s UserStatus) String() string { + switch s { + case Inactive: + return "Inactive" + case Active: + return "Active" + case Deleted: + return "Deleted" + case PendingDeletion: + return "Pending Deletion" + case LegalHold: + return "Legal Hold" + default: + return "" + } +} + // User is a database object that describes User entity. type User struct { ID uuid.UUID `json:"id"`