satellite/console: new endpoint to get total usage and limits for all the projects user owns

Added new endpoint and service method to return total usage and limits for all the projects that user owns.
It is needed for new paid tier UI

Change-Id: Ic5b67ca7b275ec4930d976a007168235c0500b70
This commit is contained in:
Vitalii Shpital 2021-06-24 18:49:15 +03:00 committed by Maximillian von Briesen
parent 4031336cbd
commit d30fd77652
10 changed files with 360 additions and 70 deletions

View File

@ -16,7 +16,7 @@ import (
var (
// ErrAPIKeysAPI - console api keys api error type.
ErrAPIKeysAPI = errs.Class("consoleapi keys")
ErrAPIKeysAPI = errs.Class("console api keys")
)
// APIKeys is an api controller that exposes all api keys related functionality.

View File

@ -16,7 +16,7 @@ import (
var (
// ErrBucketsAPI - console buckets api error type.
ErrBucketsAPI = errs.Class("consoleapi buckets")
ErrBucketsAPI = errs.Class("console api buckets")
)
// Buckets is an api controller that exposes all buckets related functionality.

View File

@ -0,0 +1,120 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/console"
)
var (
// ErrUsageLimitsAPI - console usage and limits api error type.
ErrUsageLimitsAPI = errs.Class("console usage and limits")
)
// UsageLimits is an api controller that exposes all usage and limits related functionality.
type UsageLimits struct {
log *zap.Logger
service *console.Service
}
// NewUsageLimits is a constructor for api usage and limits controller.
func NewUsageLimits(log *zap.Logger, service *console.Service) *UsageLimits {
return &UsageLimits{
log: log,
service: service,
}
}
// ProjectUsageLimits returns usage and limits by project ID.
func (ul *UsageLimits) ProjectUsageLimits(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
var ok bool
var idParam string
if idParam, ok = mux.Vars(r)["id"]; !ok {
ul.serveJSONError(w, http.StatusBadRequest, errs.New("missing project id route param"))
return
}
projectID, err := uuid.FromString(idParam)
if err != nil {
ul.serveJSONError(w, http.StatusBadRequest, errs.New("invalid project id: %v", err))
return
}
usageLimits, err := ul.service.GetProjectUsageLimits(ctx, projectID)
if err != nil {
switch {
case console.ErrUnauthorized.Has(err):
ul.serveJSONError(w, http.StatusUnauthorized, err)
return
case accounting.ErrInvalidArgument.Has(err):
ul.serveJSONError(w, http.StatusBadRequest, err)
return
default:
ul.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
err = json.NewEncoder(w).Encode(usageLimits)
if err != nil {
ul.log.Error("error encoding project usage limits", zap.Error(ErrUsageLimitsAPI.Wrap(err)))
}
}
// TotalUsageLimits returns total usage and limits for all the projects that user owns.
func (ul *UsageLimits) TotalUsageLimits(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
usageLimits, err := ul.service.GetTotalUsageLimits(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
ul.serveJSONError(w, http.StatusUnauthorized, err)
return
}
ul.serveJSONError(w, http.StatusInternalServerError, err)
return
}
err = json.NewEncoder(w).Encode(usageLimits)
if err != nil {
ul.log.Error("error encoding project usage limits", zap.Error(ErrUsageLimitsAPI.Wrap(err)))
}
}
// serveJSONError writes JSON error to response output stream.
func (ul *UsageLimits) serveJSONError(w http.ResponseWriter, status int, err error) {
ul.log.Error("returning error to client", zap.Int("code", status), zap.Error(err))
w.WriteHeader(status)
var response struct {
Error string `json:"error"`
}
response.Error = err.Error()
err = json.NewEncoder(w).Encode(response)
if err != nil {
ul.log.Error("failed to write json error response", zap.Error(ErrUsageLimitsAPI.Wrap(err)))
}
}

View File

@ -0,0 +1,115 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi_test
import (
"encoding/json"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/testcontext"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
)
func Test_TotalUsageLimits(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.OpenRegistrationEnabled = true
config.Console.RateLimit.Burst = 10
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
newUser := console.CreateUser{
FullName: "Usage Limit Test",
ShortName: "",
Email: "ul@test.test",
}
user, err := sat.AddUser(ctx, newUser, 3)
require.NoError(t, err)
project0, err := sat.AddProject(ctx, user.ID, "testProject0")
require.NoError(t, err)
project1, err := sat.AddProject(ctx, user.ID, "testProject1")
require.NoError(t, err)
project2, err := sat.AddProject(ctx, user.ID, "testProject2")
require.NoError(t, err)
const expectedLimit = 15
err = sat.DB.ProjectAccounting().UpdateProjectUsageLimit(ctx, project0.ID, expectedLimit)
require.NoError(t, err)
err = sat.DB.ProjectAccounting().UpdateProjectBandwidthLimit(ctx, project0.ID, expectedLimit)
require.NoError(t, err)
err = sat.DB.ProjectAccounting().UpdateProjectUsageLimit(ctx, project1.ID, expectedLimit)
require.NoError(t, err)
err = sat.DB.ProjectAccounting().UpdateProjectBandwidthLimit(ctx, project1.ID, expectedLimit)
require.NoError(t, err)
err = sat.DB.ProjectAccounting().UpdateProjectUsageLimit(ctx, project2.ID, expectedLimit)
require.NoError(t, err)
err = sat.DB.ProjectAccounting().UpdateProjectBandwidthLimit(ctx, project2.ID, expectedLimit)
require.NoError(t, err)
// we are using full name as a password
token, err := sat.API.Console.Service.Token(ctx, user.Email, user.FullName)
require.NoError(t, err)
client := http.Client{}
req, err := http.NewRequestWithContext(
ctx,
"GET",
"http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/projects/usage-limits",
nil,
)
require.NoError(t, err)
expire := time.Now().AddDate(0, 0, 1)
cookie := http.Cookie{
Name: "_tokenKey",
Path: "/",
Value: token,
Expires: expire,
}
req.AddCookie(&cookie)
result, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, result.StatusCode)
body, err := ioutil.ReadAll(result.Body)
require.NoError(t, err)
var output console.ProjectUsageLimits
err = json.Unmarshal(body, &output)
require.NoError(t, err)
require.Equal(t, int64(0), output.BandwidthUsed)
require.Equal(t, int64(0), output.StorageUsed)
require.Equal(t, int64(expectedLimit*3), output.BandwidthLimit)
require.Equal(t, int64(expectedLimit*3), output.StorageLimit)
defer func() {
err = result.Body.Close()
require.NoError(t, err)
}()
})
}

View File

@ -33,7 +33,6 @@ import (
"storj.io/common/storj"
"storj.io/common/uuid"
"storj.io/storj/private/web"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
@ -206,9 +205,14 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail
router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler)))
usageLimitsController := consoleapi.NewUsageLimits(logger, service)
router.Handle(
"/api/v0/projects/{id}/usage-limits",
server.withAuth(http.HandlerFunc(server.projectUsageLimitsHandler)),
server.withAuth(http.HandlerFunc(usageLimitsController.ProjectUsageLimits)),
).Methods(http.MethodGet)
router.Handle(
"/api/v0/projects/usage-limits",
server.withAuth(http.HandlerFunc(usageLimitsController.TotalUsageLimits)),
).Methods(http.MethodGet)
authController := consoleapi.NewAuth(logger, service, mailService, server.cookieAuth, partners, server.analytics, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL)
@ -621,67 +625,6 @@ func (server *Server) cancelPasswordRecoveryHandler(w http.ResponseWriter, r *ht
http.Redirect(w, r, "https://storjlabs.atlassian.net/servicedesk/customer/portals", http.StatusSeeOther)
}
// projectUsageLimitsHandler api handler for project usage limits.
func (server *Server) projectUsageLimitsHandler(w http.ResponseWriter, r *http.Request) {
err := error(nil)
ctx := r.Context()
defer mon.Task()(&ctx)(&err)
var ok bool
var idParam string
handleError := func(code int, err error) {
w.WriteHeader(code)
var jsonError struct {
Error string `json:"error"`
}
// N.B. we are probably leaking internal details to the client
jsonError.Error = err.Error()
if err := json.NewEncoder(w).Encode(jsonError); err != nil {
server.log.Error("error encoding project usage limits error", zap.Error(err))
}
}
handleServiceError := func(err error) {
switch {
case console.ErrUnauthorized.Has(err):
handleError(http.StatusUnauthorized, err)
case accounting.ErrInvalidArgument.Has(err):
handleError(http.StatusBadRequest, err)
default:
handleError(http.StatusInternalServerError, err)
}
}
w.Header().Set("Content-Type", "application/json")
if idParam, ok = mux.Vars(r)["id"]; !ok {
handleError(http.StatusBadRequest, errs.New("missing project id route param"))
return
}
projectID, err := uuid.FromString(idParam)
if err != nil {
handleError(http.StatusBadRequest, errs.New("invalid project id: %v", err))
return
}
limits, err := server.service.GetProjectUsageLimits(ctx, projectID)
if err != nil {
handleServiceError(err)
return
}
if err := json.NewEncoder(w).Encode(limits); err != nil {
server.log.Error("error encoding project usage limits", zap.Error(err))
return
}
}
// graphqlHandler is graphql endpoint http handler function.
func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -1517,27 +1517,86 @@ func (s *Service) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID
func (s *Service) GetProjectUsageLimits(ctx context.Context, projectID uuid.UUID) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
_, err = s.getAuthAndAuditLog(ctx, "get project usage limits", zap.String("projectID", projectID.String()))
auth, err := s.getAuthAndAuditLog(ctx, "get project usage limits", zap.String("projectID", projectID.String()))
if err != nil {
return nil, Error.Wrap(err)
}
storageLimit, err := s.projectUsage.GetProjectStorageLimit(ctx, projectID)
if _, err = s.isProjectMember(ctx, auth.User.ID, projectID); err != nil {
return nil, Error.Wrap(err)
}
prUsageLimits, err := s.getProjectUsageLimits(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
bandwidthLimit, err := s.projectUsage.GetProjectBandwidthLimit(ctx, projectID)
return &ProjectUsageLimits{
StorageLimit: prUsageLimits.StorageLimit,
BandwidthLimit: prUsageLimits.BandwidthLimit,
StorageUsed: prUsageLimits.StorageUsed,
BandwidthUsed: prUsageLimits.BandwidthUsed,
}, nil
}
// GetTotalUsageLimits returns total limits and current usage for all the projects.
func (s *Service) GetTotalUsageLimits(ctx context.Context) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
auth, err := s.getAuthAndAuditLog(ctx, "get total usage and limits for all the projects")
if err != nil {
return nil, Error.Wrap(err)
}
projects, err := s.store.Projects().GetOwn(ctx, auth.User.ID)
if err != nil {
return nil, Error.Wrap(err)
}
var totalStorageLimit int64
var totalBandwidthLimit int64
var totalStorageUsed int64
var totalBandwidthUsed int64
for _, pr := range projects {
prUsageLimits, err := s.getProjectUsageLimits(ctx, pr.ID)
if err != nil {
return nil, Error.Wrap(err)
}
totalStorageLimit += prUsageLimits.StorageLimit
totalBandwidthLimit += prUsageLimits.BandwidthLimit
totalStorageUsed += prUsageLimits.StorageUsed
totalBandwidthUsed += prUsageLimits.BandwidthUsed
}
return &ProjectUsageLimits{
StorageLimit: totalStorageLimit,
BandwidthLimit: totalBandwidthLimit,
StorageUsed: totalStorageUsed,
BandwidthUsed: totalBandwidthUsed,
}, nil
}
func (s *Service) getProjectUsageLimits(ctx context.Context, projectID uuid.UUID) (_ *ProjectUsageLimits, err error) {
defer mon.Task()(&ctx)(&err)
storageLimit, err := s.projectUsage.GetProjectStorageLimit(ctx, projectID)
if err != nil {
return nil, err
}
bandwidthLimit, err := s.projectUsage.GetProjectBandwidthLimit(ctx, projectID)
if err != nil {
return nil, err
}
storageUsed, err := s.projectUsage.GetProjectStorageTotals(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
return nil, err
}
bandwidthUsed, err := s.projectUsage.GetProjectBandwidthTotals(ctx, projectID)
if err != nil {
return nil, Error.Wrap(err)
return nil, err
}
return &ProjectUsageLimits{

View File

@ -151,6 +151,33 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
throw new Error('can not get usage limits');
}
/**
* Get total limits for all the projects that user owns.
*
* throws Error
*/
public async getTotalLimits(): Promise<ProjectLimits> {
const path = `${this.ROOT_PATH}/usage-limits`;
const response = await this.http.get(path);
if (response.ok) {
const limits = await response.json();
return new ProjectLimits(
limits.bandwidthLimit,
limits.bandwidthUsed,
limits.storageLimit,
limits.storageUsed,
);
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('can not get total usage limits');
}
/**
* Fetch owned projects.
*

View File

@ -22,6 +22,7 @@ export const PROJECTS_ACTIONS = {
DELETE: 'deleteProject',
CLEAR: 'clearProjects',
GET_LIMITS: 'getProjectLimits',
GET_TOTAL_LIMITS: 'getTotalLimits',
};
export const PROJECTS_MUTATIONS = {
@ -33,6 +34,7 @@ export const PROJECTS_MUTATIONS = {
SELECT_PROJECT: 'SELECT_PROJECT',
CLEAR_PROJECTS: 'CLEAR_PROJECTS',
SET_LIMITS: 'SET_PROJECT_LIMITS',
SET_TOTAL_LIMITS: 'SET_TOTAL_LIMITS',
SET_PAGE_NUMBER: 'SET_PAGE_NUMBER',
SET_PAGE: 'SET_PAGE',
};
@ -43,6 +45,7 @@ export class ProjectsState {
public projects: Project[] = [];
public selectedProject: Project = defaultSelectedProject;
public currentLimits: ProjectLimits = new ProjectLimits();
public totalLimits: ProjectLimits = new ProjectLimits();
public cursor: ProjectsCursor = new ProjectsCursor();
public page: ProjectsPage = new ProjectsPage();
}
@ -57,6 +60,7 @@ const {
DELETE,
CLEAR,
GET_LIMITS,
GET_TOTAL_LIMITS,
FETCH_OWNED,
} = PROJECTS_ACTIONS;
@ -69,6 +73,7 @@ const {
SELECT_PROJECT,
CLEAR_PROJECTS,
SET_LIMITS,
SET_TOTAL_LIMITS,
SET_PAGE_NUMBER,
SET_PAGE,
} = PROJECTS_MUTATIONS;
@ -129,6 +134,9 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState>
[SET_LIMITS](state: ProjectsState, limits: ProjectLimits): void {
state.currentLimits = limits;
},
[SET_TOTAL_LIMITS](state: ProjectsState, limits: ProjectLimits): void {
state.totalLimits = limits;
},
[CLEAR_PROJECTS](state: ProjectsState): void {
state.projects = [];
state.selectedProject = defaultSelectedProject;
@ -202,6 +210,13 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState>
return limits;
},
[GET_TOTAL_LIMITS]: async function ({commit}: any): Promise<ProjectLimits> {
const limits = await api.getTotalLimits();
commit(SET_TOTAL_LIMITS, limits);
return limits;
},
[CLEAR]: function({commit}: any): void {
commit(CLEAR_PROJECTS);
},

View File

@ -45,6 +45,13 @@ export interface ProjectsApi {
*/
getLimits(projectId: string): Promise<ProjectLimits>;
/**
* Get project limits.
*
* throws Error
*/
getTotalLimits(): Promise<ProjectLimits>;
/**
* Fetch owned projects.
*

View File

@ -42,4 +42,8 @@ export class ProjectsApiMock implements ProjectsApi {
getLimits(projectId: string): Promise<ProjectLimits> {
return Promise.resolve(this.mockLimits);
}
getTotalLimits(): Promise<ProjectLimits> {
return Promise.resolve(this.mockLimits);
}
}