From 4b033c266a6ff254f3b9a9cb59b7226499e0f42d Mon Sep 17 00:00:00 2001 From: He Weiwei Date: Thu, 25 Feb 2021 08:19:55 +0000 Subject: [PATCH] refactor: generate quota APIs by go-swagger Signed-off-by: He Weiwei --- api/v2.0/legacy_swagger.yaml | 206 --------------- api/v2.0/swagger.yaml | 165 +++++++++++- src/common/config/metadata/metadatalist.go | 1 - src/common/const.go | 1 - src/common/models/project.go | 23 -- src/core/api/harborapi_test.go | 56 ---- src/core/api/quota.go | 149 ----------- src/core/api/quota_test.go | 140 ---------- src/pkg/quota/types/resources.go | 7 + src/server/v2.0/handler/handler.go | 1 + src/server/v2.0/handler/model/quota.go | 67 +++++ src/server/v2.0/handler/project.go | 20 +- src/server/v2.0/handler/quota.go | 116 +++++++++ src/server/v2.0/handler/quota_test.go | 283 +++++++++++++++++++++ src/server/v2.0/route/legacy.go | 3 - src/testing/apitests/apilib/quota.go | 39 --- tests/apitests/python/library/base.py | 3 +- tests/apitests/python/library/system.py | 4 +- 18 files changed, 652 insertions(+), 632 deletions(-) delete mode 100644 src/core/api/quota.go delete mode 100644 src/core/api/quota_test.go create mode 100644 src/server/v2.0/handler/model/quota.go create mode 100644 src/server/v2.0/handler/quota.go create mode 100644 src/server/v2.0/handler/quota_test.go delete mode 100644 src/testing/apitests/apilib/quota.go diff --git a/api/v2.0/legacy_swagger.yaml b/api/v2.0/legacy_swagger.yaml index a60cf6ab5..998b4c1bb 100644 --- a/api/v2.0/legacy_swagger.yaml +++ b/api/v2.0/legacy_swagger.yaml @@ -1806,120 +1806,6 @@ paths: description: User does not have permission to call this API. '500': description: Unexpected internal errors. - '/quotas': - get: - summary: List quotas - description: List quotas - tags: - - Products - parameters: - - name: reference - in: query - description: The reference type of quota. - required: false - type: string - - name: reference_id - in: query - description: The reference id of quota. - required: false - type: string - - name: sort - in: query - type: string - required: false - description: | - Sort method, valid values include: - 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. - Here '-' stands for descending order, resource_name should be the real resource name of the quota. - - name: page - in: query - type: integer - format: int32 - required: false - description: 'The page number, default is 1.' - - name: page_size - in: query - type: integer - format: int32 - required: false - description: 'The size of per page, default is 10, maximum is 100.' - responses: - '200': - description: Successfully retrieved the quotas. - schema: - type: array - items: - $ref: '#/definitions/Quota' - headers: - X-Total-Count: - description: The total count of access logs - type: integer - Link: - description: Link refers to the previous page and next page - type: string - '401': - description: User is not authenticated. - '403': - description: User does not have permission to call this API. - '500': - description: Unexpected internal errors. - '/quotas/{id}': - get: - summary: Get the specified quota - description: Get the specified quota - tags: - - Products - - Quota - parameters: - - name: id - in: path - type: integer - required: true - description: Quota ID - responses: - '200': - description: Successfully retrieved the quota. - schema: - $ref: '#/definitions/Quota' - '401': - description: User need to log in first. - '403': - description: User does not have permission to call this API - '404': - description: Quota does not exist. - '500': - description: Unexpected internal errors. - put: - summary: Update the specified quota - description: Update hard limits of the specified quota - tags: - - Products - - Quota - parameters: - - name: id - in: path - type: integer - required: true - description: Quota ID - - name: hard - in: body - required: true - description: The new hard limits for the quota - schema: - $ref: '#/definitions/QuotaUpdateReq' - responses: - '200': - description: Updated quota hard limits successfully. - '400': - description: Illegal format of quota update request. - '401': - description: User need to log in first. - '403': - description: User does not have permission to the quota. - '404': - description: Quota ID does not exist. - '500': - description: Unexpected internal errors. '/projects/{project_id}/webhook/policies': get: summary: List project webhook policies. @@ -2736,30 +2622,6 @@ definitions: artifact_count: type: integer description: The count of artifacts in the repository - ProjectReq: - type: object - properties: - project_name: - type: string - description: The name of the project. - metadata: - description: The metadata of the project. - $ref: '#/definitions/ProjectMetadata' - cve_allowlist: - description: The CVE allowlist of the project. - $ref: '#/definitions/CVEAllowlist' - count_limit: - type: integer - format: int64 - description: The count quota of the project. - storage_limit: - type: integer - format: int64 - description: The storage quota of the project. - registry_id: - type: integer - format: int64 - description: The ID of referenced registry when creating the proxy cache project Project: type: object properties: @@ -2836,38 +2698,6 @@ definitions: type: string description: 'Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored.' - ProjectSummary: - type: object - properties: - repo_count: - type: integer - description: The number of the repositories under this project. - chart_count: - type: integer - description: The total number of charts under this project. - project_admin_count: - type: integer - description: The total number of project admin members. - maintainer_count: - type: integer - description: The total number of maintainer members. - developer_count: - type: integer - description: The total number of developer members. - guest_count: - type: integer - description: The total number of guest members. - quota: - type: object - properties: - hard: - $ref: "#/definitions/ResourceList" - description: The hard limits of the quota - used: - $ref: "#/definitions/ResourceList" - description: The used status of the quota - registry: - $ref: "#/definitions/Registry" User: type: object properties: @@ -3833,42 +3663,6 @@ definitions: cve_id: type: string description: The ID of the CVE, such as "CVE-2019-10164" - ResourceList: - type: object - additionalProperties: - type: integer - format: int64 - QuotaUpdateReq: - type: object - properties: - hard: - $ref: "#/definitions/ResourceList" - description: The new hard limits for the quota - QuotaRefObject: - type: object - additionalProperties: {} - Quota: - type: object - description: The quota object - properties: - id: - type: integer - description: ID of the quota - ref: - $ref: "#/definitions/QuotaRefObject" - description: The reference object of the quota - hard: - $ref: "#/definitions/ResourceList" - description: The hard limits of the quota - used: - $ref: "#/definitions/ResourceList" - description: The used status of the quota - creation_time: - type: string - description: the creation time of the quota - update_time: - type: string - description: the update time of the quota WebhookTargetObject: type: object description: The webhook policy target object. diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 7a405c980..747940ef7 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -1700,6 +1700,114 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + '/quotas': + get: + summary: List quotas + description: List quotas + tags: + - quota + operationId: listQuotas + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/page' + - $ref: '#/parameters/pageSize' + - name: reference + in: query + description: The reference type of quota. + required: false + type: string + - name: reference_id + in: query + description: The reference id of quota. + required: false + type: string + - name: sort + in: query + type: string + required: false + description: | + Sort method, valid values include: + 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + Here '-' stands for descending order, resource_name should be the real resource name of the quota. + responses: + '200': + description: Successfully retrieved the quotas. + schema: + type: array + items: + $ref: '#/definitions/Quota' + headers: + X-Total-Count: + description: The total count of access logs + type: integer + Link: + description: Link refers to the previous page and next page + type: string + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '500': + $ref: '#/responses/500' + '/quotas/{id}': + get: + summary: Get the specified quota + description: Get the specified quota + tags: + - quota + operationId: getQuota + parameters: + - $ref: '#/parameters/requestId' + - name: id + in: path + type: integer + required: true + description: Quota ID + responses: + '200': + description: Successfully retrieved the quota. + schema: + $ref: '#/definitions/Quota' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' + put: + summary: Update the specified quota + description: Update hard limits of the specified quota + tags: + - quota + operationId: updateQuota + parameters: + - $ref: '#/parameters/requestId' + - name: id + in: path + type: integer + required: true + description: Quota ID + - name: hard + in: body + required: true + description: The new hard limits for the quota + schema: + $ref: '#/definitions/QuotaUpdateReq' + responses: + '200': + $ref: '#/responses/200' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' /robots/{robot_id}: get: summary: Get a robot account @@ -3585,16 +3693,18 @@ definitions: type: integer description: The total number of limited guest members. quota: - type: object - properties: - hard: - $ref: "#/definitions/ResourceList" - description: The hard limits of the quota - used: - $ref: "#/definitions/ResourceList" - description: The used status of the quota + $ref: "#/definitions/ProjectSummaryQuota" registry: $ref: "#/definitions/Registry" + ProjectSummaryQuota: + type: object + properties: + hard: + $ref: "#/definitions/ResourceList" + description: The hard limits of the quota + used: + $ref: "#/definitions/ResourceList" + description: The used status of the quota CVEAllowlist: type: object description: The CVE Allowlist for system or project @@ -3678,6 +3788,10 @@ definitions: additionalProperties: type: integer format: int64 + x-go-type: + type: ResourceList + import: + package: "github.com/goharbor/harbor/src/pkg/quota/types" ReplicationExecution: type: object description: The replication execution @@ -4328,3 +4442,38 @@ definitions: retained: type: integer x-omitempty: false + QuotaUpdateReq: + type: object + properties: + hard: + $ref: "#/definitions/ResourceList" + description: The new hard limits for the quota + QuotaRefObject: + type: object + additionalProperties: {} + Quota: + type: object + description: The quota object + properties: + id: + type: integer + description: ID of the quota + ref: + $ref: "#/definitions/QuotaRefObject" + description: The reference object of the quota + hard: + $ref: "#/definitions/ResourceList" + description: The hard limits of the quota + x-omitempty: false + used: + $ref: "#/definitions/ResourceList" + description: The used status of the quota + x-omitempty: false + creation_time: + type: string + format: date-time + description: the creation time of the quota + update_time: + type: string + format: date-time + description: the update time of the quota diff --git a/src/common/config/metadata/metadatalist.go b/src/common/config/metadata/metadatalist.go index d6f32a20a..2ab4cad46 100644 --- a/src/common/config/metadata/metadatalist.go +++ b/src/common/config/metadata/metadatalist.go @@ -158,7 +158,6 @@ var ( {Name: common.MetricPath, Scope: SystemScope, Group: BasicGroup, EnvKey: "METRIC_PATH", DefaultValue: "/metrics", ItemType: &StringType{}, Editable: true}, {Name: common.QuotaPerProjectEnable, Scope: UserScope, Group: QuotaGroup, EnvKey: "QUOTA_PER_PROJECT_ENABLE", DefaultValue: "true", ItemType: &BoolType{}, Editable: true}, - {Name: common.CountPerProject, Scope: UserScope, Group: QuotaGroup, EnvKey: "COUNT_PER_PROJECT", DefaultValue: "-1", ItemType: &QuotaType{}, Editable: true}, {Name: common.StoragePerProject, Scope: UserScope, Group: QuotaGroup, EnvKey: "STORAGE_PER_PROJECT", DefaultValue: "-1", ItemType: &QuotaType{}, Editable: true}, } ) diff --git a/src/common/const.go b/src/common/const.go index 42525dbb4..889c2f3d0 100755 --- a/src/common/const.go +++ b/src/common/const.go @@ -156,7 +156,6 @@ const ( // Quota setting items for project QuotaPerProjectEnable = "quota_per_project_enable" - CountPerProject = "count_per_project" StoragePerProject = "storage_per_project" // DefaultGCTimeWindowHours is the reserve blob time window used by GC, default is 2 hours diff --git a/src/common/models/project.go b/src/common/models/project.go index 88ea2b8fc..04889d04a 100644 --- a/src/common/models/project.go +++ b/src/common/models/project.go @@ -22,8 +22,6 @@ import ( "time" "github.com/astaxie/beego/orm" - "github.com/goharbor/harbor/src/pkg/quota/types" - "github.com/goharbor/harbor/src/replication/model" "github.com/lib/pq" ) @@ -263,24 +261,3 @@ type ProjectQueryResult struct { func (p *Project) TableName() string { return ProjectTable } - -// QuotaSummary ... -type QuotaSummary struct { - Hard types.ResourceList `json:"hard"` - Used types.ResourceList `json:"used"` -} - -// ProjectSummary ... -type ProjectSummary struct { - RepoCount int64 `json:"repo_count"` - ChartCount uint64 `json:"chart_count"` - - ProjectAdminCount int64 `json:"project_admin_count"` - MaintainerCount int64 `json:"maintainer_count"` - DeveloperCount int64 `json:"developer_count"` - GuestCount int64 `json:"guest_count"` - LimitedGuestCount int64 `json:"limited_guest_count"` - - Quota *QuotaSummary `json:"quota,omitempty"` - Registry *model.Registry `json:"registry"` -} diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 9c06960b5..cf2745823 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -157,10 +157,6 @@ func init() { beego.Router("/api/"+api.APIVersion+"/chartrepo/:repo/charts/:name/:version/labels", chartLabelAPIType, "get:GetLabels;post:MarkLabel") beego.Router("/api/"+api.APIVersion+"/chartrepo/:repo/charts/:name/:version/labels/:id([0-9]+)", chartLabelAPIType, "delete:RemoveLabel") - quotaAPIType := &QuotaAPI{} - beego.Router("/api/quotas", quotaAPIType, "get:List") - beego.Router("/api/quotas/:id([0-9]+)", quotaAPIType, "get:Get;put:Put") - beego.Router("/api/internal/switchquota", &InternalAPI{}, "put:SwitchQuota") beego.Router("/api/internal/syncquota", &InternalAPI{}, "post:SyncQuota") @@ -918,55 +914,3 @@ func (a testapi) RegistryUpdate(authInfo usrInfo, registryID int64, req *apimode return code, nil } - -// QuotasGet returns quotas -func (a testapi) QuotasGet(query *apilib.QuotaQuery, authInfo ...usrInfo) (int, []apilib.Quota, error) { - _sling := sling.New().Get(a.basePath). - Path("api/quotas"). - QueryStruct(query) - - var successPayload []apilib.Quota - - var httpStatusCode int - var err error - var body []byte - if len(authInfo) > 0 { - httpStatusCode, body, err = request(_sling, jsonAcceptHeader, authInfo[0]) - } else { - httpStatusCode, body, err = request(_sling, jsonAcceptHeader) - } - - if err == nil && httpStatusCode == 200 { - err = json.Unmarshal(body, &successPayload) - } else { - log.Println(string(body)) - } - - return httpStatusCode, successPayload, err -} - -// Return specific quota -func (a testapi) QuotasGetByID(authInfo usrInfo, quotaID string) (int, apilib.Quota, error) { - _sling := sling.New().Get(a.basePath) - - // create api path - path := "api/quotas/" + quotaID - _sling = _sling.Path(path) - - var successPayload apilib.Quota - - httpStatusCode, body, err := request(_sling, jsonAcceptHeader, authInfo) - if err == nil && httpStatusCode == 200 { - err = json.Unmarshal(body, &successPayload) - } - return httpStatusCode, successPayload, err -} - -// Update spec for the quota -func (a testapi) QuotasPut(authInfo usrInfo, quotaID string, req QuotaUpdateRequest) (int, error) { - path := "/api/quotas/" + quotaID - _sling := sling.New().Put(a.basePath).Path(path).BodyJSON(req) - - httpStatusCode, _, err := request(_sling, jsonAcceptHeader, authInfo) - return httpStatusCode, err -} diff --git a/src/core/api/quota.go b/src/core/api/quota.go deleted file mode 100644 index e64d2f5d3..000000000 --- a/src/core/api/quota.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package api - -import ( - "fmt" - "github.com/goharbor/harbor/src/common/rbac" - "github.com/goharbor/harbor/src/controller/quota" - "github.com/goharbor/harbor/src/lib/errors" - "github.com/goharbor/harbor/src/lib/orm" - "github.com/goharbor/harbor/src/lib/q" - "github.com/goharbor/harbor/src/pkg/quota/types" -) - -// QuotaUpdateRequest struct for the body of put quota API -type QuotaUpdateRequest struct { - Hard types.ResourceList `json:"hard"` -} - -// QuotaAPI handles request to /api/quotas/ -type QuotaAPI struct { - BaseController - id int64 -} - -// Prepare validates the URL and the user -func (qa *QuotaAPI) Prepare() { - qa.BaseController.Prepare() - - if !qa.SecurityCtx.IsAuthenticated() { - qa.SendUnAuthorizedError(errors.New("Unauthorized")) - return - } - - if len(qa.GetStringFromPath(":id")) != 0 { - id, err := qa.GetInt64FromPath(":id") - if err != nil || id <= 0 { - text := "invalid quota ID: " - if err != nil { - text += err.Error() - } else { - text += fmt.Sprintf("%d", id) - } - qa.SendBadRequestError(errors.New(text)) - return - } - qa.id = id - } -} - -// Get returns quota by id -func (qa *QuotaAPI) Get() { - if !qa.SecurityCtx.Can(orm.Context(), rbac.ActionRead, rbac.ResourceQuota) { - qa.SendForbiddenError(errors.New(qa.SecurityCtx.GetUsername())) - return - } - quota, err := quota.Ctl.Get(qa.Ctx.Request.Context(), qa.id) - if err != nil { - qa.SendError(err) - return - } - qa.Data["json"] = quota - qa.ServeJSON() -} - -// Put update the quota -func (qa *QuotaAPI) Put() { - if !qa.SecurityCtx.Can(orm.Context(), rbac.ActionUpdate, rbac.ResourceQuota) { - qa.SendForbiddenError(errors.New(qa.SecurityCtx.GetUsername())) - return - } - - var req *QuotaUpdateRequest - if err := qa.DecodeJSONReq(&req); err != nil { - qa.SendBadRequestError(err) - return - } - - ctx := qa.Ctx.Request.Context() - q, err := quota.Ctl.Get(ctx, qa.id) - if err != nil { - qa.SendError(err) - return - } - if err := quota.Validate(ctx, q.Reference, req.Hard); err != nil { - qa.SendBadRequestError(err) - return - } - - q.SetHard(req.Hard) - - if err := quota.Ctl.Update(ctx, q); err != nil { - qa.SendInternalServerError(fmt.Errorf("failed to update hard limits of the quota, error: %v", err)) - return - } -} - -// List returns quotas by query -func (qa *QuotaAPI) List() { - if !qa.SecurityCtx.Can(orm.Context(), rbac.ActionList, rbac.ResourceQuota) { - qa.SendForbiddenError(errors.New(qa.SecurityCtx.GetUsername())) - return - } - page, size, err := qa.GetPaginationParams() - if err != nil { - qa.SendBadRequestError(err) - return - } - - query := &q.Query{ - Keywords: q.KeyWords{ - "reference": qa.GetString("reference"), - "reference_id": qa.GetString("reference_id"), - }, - PageNumber: page, - PageSize: size, - Sorting: qa.GetString("sort"), - } - - ctx := qa.Ctx.Request.Context() - - total, err := quota.Ctl.Count(ctx, query) - if err != nil { - qa.SendInternalServerError(fmt.Errorf("failed to query database for total of quotas, error: %v", err)) - return - } - - quotas, err := quota.Ctl.List(ctx, query, quota.WithReferenceObject()) - if err != nil { - qa.SendInternalServerError(fmt.Errorf("failed to query database for quotas, error: %v", err)) - return - } - - qa.SetPaginationHeader(total, page, size) - qa.Data["json"] = quotas - qa.ServeJSON() -} diff --git a/src/core/api/quota_test.go b/src/core/api/quota_test.go deleted file mode 100644 index 61cd24a53..000000000 --- a/src/core/api/quota_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package api - -import ( - "context" - "fmt" - "testing" - - o "github.com/astaxie/beego/orm" - "github.com/goharbor/harbor/src/controller/quota" - "github.com/goharbor/harbor/src/lib/orm" - "github.com/goharbor/harbor/src/pkg/quota/driver" - "github.com/goharbor/harbor/src/pkg/quota/types" - "github.com/goharbor/harbor/src/testing/apitests/apilib" - "github.com/goharbor/harbor/src/testing/mock" - drivertesting "github.com/goharbor/harbor/src/testing/pkg/quota/driver" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -var ( - reference = uuid.New().String() - hardLimits = types.ResourceList{types.ResourceStorage: -1} -) - -func init() { - mockDriver := &drivertesting.Driver{} - - mockHardLimitsFn := func() types.ResourceList { - return hardLimits - } - - mockLoadFn := func(ctx context.Context, key string) driver.RefObject { - return driver.RefObject{"id": key} - } - - mockValidateFn := func(hardLimits types.ResourceList) error { - if len(hardLimits) == 0 { - return fmt.Errorf("no resources found") - } - - return nil - } - - mockDriver.On("HardLimits").Return(mockHardLimitsFn) - mock.OnAnything(mockDriver, "Load").Return(mockLoadFn, nil) - mock.OnAnything(mockDriver, "Validate").Return(mockValidateFn) - - driver.Register(reference, mockDriver) -} - -func TestQuotaAPIList(t *testing.T) { - assert := assert.New(t) - apiTest := newHarborAPI() - - ctx := orm.NewContext(context.TODO(), o.NewOrm()) - var quotaIDs []int64 - defer func() { - for _, quotaID := range quotaIDs { - quota.Ctl.Delete(ctx, quotaID) - } - }() - - count := 10 - for i := 0; i < count; i++ { - quotaID, err := quota.Ctl.Create(ctx, reference, uuid.New().String(), hardLimits) - assert.Nil(err) - quotaIDs = append(quotaIDs, quotaID) - } - - code, quotas, err := apiTest.QuotasGet(&apilib.QuotaQuery{Reference: reference}, *admin) - assert.Nil(err) - assert.Equal(int(200), code) - assert.Len(quotas, count, fmt.Sprintf("quotas len should be %d", count)) - - code, quotas, err = apiTest.QuotasGet(&apilib.QuotaQuery{Reference: reference, PageSize: 1}, *admin) - assert.Nil(err) - assert.Equal(int(200), code) - assert.Len(quotas, 1) -} - -func TestQuotaAPIGet(t *testing.T) { - assert := assert.New(t) - apiTest := newHarborAPI() - - ctx := orm.NewContext(context.TODO(), o.NewOrm()) - quotaID, err := quota.Ctl.Create(ctx, reference, uuid.New().String(), hardLimits) - assert.Nil(err) - defer quota.Ctl.Delete(ctx, quotaID) - - code, quota, err := apiTest.QuotasGetByID(*admin, fmt.Sprintf("%d", quotaID)) - assert.Nil(err) - assert.Equal(int(200), code) - assert.Equal(map[string]int64{"storage": -1}, quota.Hard) - - code, _, err = apiTest.QuotasGetByID(*admin, "100") - assert.Nil(err) - assert.Equal(int(404), code) -} - -func TestQuotaPut(t *testing.T) { - assert := assert.New(t) - apiTest := newHarborAPI() - - ctx := orm.NewContext(context.TODO(), o.NewOrm()) - quotaID, err := quota.Ctl.Create(ctx, reference, uuid.New().String(), hardLimits) - assert.Nil(err) - defer quota.Ctl.Delete(ctx, quotaID) - - code, quota, err := apiTest.QuotasGetByID(*admin, fmt.Sprintf("%d", quotaID)) - assert.Nil(err) - assert.Equal(int(200), code) - assert.Equal(map[string]int64{"storage": -1}, quota.Hard) - - code, err = apiTest.QuotasPut(*admin, fmt.Sprintf("%d", quotaID), QuotaUpdateRequest{}) - assert.Nil(err, err) - assert.Equal(int(400), code) - - code, err = apiTest.QuotasPut(*admin, fmt.Sprintf("%d", quotaID), QuotaUpdateRequest{Hard: types.ResourceList{types.ResourceStorage: 100}}) - assert.Nil(err) - assert.Equal(int(200), code) - - code, quota, err = apiTest.QuotasGetByID(*admin, fmt.Sprintf("%d", quotaID)) - assert.Nil(err) - assert.Equal(int(200), code) - assert.Equal(map[string]int64{"storage": 100}, quota.Hard) -} diff --git a/src/pkg/quota/types/resources.go b/src/pkg/quota/types/resources.go index 45ba8002a..306bf9059 100644 --- a/src/pkg/quota/types/resources.go +++ b/src/pkg/quota/types/resources.go @@ -17,6 +17,8 @@ package types import ( "encoding/json" "strconv" + + "github.com/go-openapi/strfmt" ) const ( @@ -43,6 +45,11 @@ func (resource ResourceName) FormatValue(value int64) string { // ResourceList is a set of (resource name, value) pairs. type ResourceList map[ResourceName]int64 +// Validate validates this resource list +func (resources ResourceList) Validate(formats strfmt.Registry) error { + return nil +} + func (resources ResourceList) String() string { bytes, _ := json.Marshal(resources) return string(bytes) diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index 7fe93465d..62b58b542 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -45,6 +45,7 @@ func New() http.Handler { SysteminfoAPI: newSystemInfoAPI(), PingAPI: newPingAPI(), GCAPI: newGCAPI(), + QuotaAPI: newQuotaAPI(), RetentionAPI: newRetentionAPI(), }) if err != nil { diff --git a/src/server/v2.0/handler/model/quota.go b/src/server/v2.0/handler/model/quota.go new file mode 100644 index 000000000..db1ebcc59 --- /dev/null +++ b/src/server/v2.0/handler/model/quota.go @@ -0,0 +1,67 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/pkg/quota" + "github.com/goharbor/harbor/src/pkg/quota/types" + "github.com/goharbor/harbor/src/server/v2.0/models" +) + +// Quota model +type Quota struct { + *quota.Quota +} + +// ToSwagger converts the quota to the swagger model +func (q *Quota) ToSwagger(ctx context.Context) *models.Quota { + if q.Quota == nil { + return nil + } + + hard, err := q.GetHard() + if err != nil { + fields := log.Fields{"quota_id": q.ID, "error": err} + log.G(ctx).WithFields(fields).Warningf("failed to get hard from quota") + + hard = types.ResourceList{} + } + + used, err := q.GetUsed() + if err != nil { + fields := log.Fields{"quota_id": q.ID, "error": err} + log.G(ctx).WithFields(fields).Warningf("failed to get used from quota") + + used = types.ResourceList{} + } + + return &models.Quota{ + ID: q.ID, + Ref: q.Ref, + Hard: hard, + Used: used, + CreationTime: strfmt.DateTime(q.CreationTime), + UpdateTime: strfmt.DateTime(q.UpdateTime), + } +} + +// NewQuota new quota instance +func NewQuota(quota *quota.Quota) *Quota { + return &Quota{Quota: quota} +} diff --git a/src/server/v2.0/handler/project.go b/src/server/v2.0/handler/project.go index 576da5a3d..1e7f18e7d 100644 --- a/src/server/v2.0/handler/project.go +++ b/src/server/v2.0/handler/project.go @@ -1,9 +1,22 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package handler import ( "context" "fmt" - "github.com/goharbor/harbor/src/controller/retention" "strconv" "strings" "sync" @@ -19,6 +32,7 @@ import ( "github.com/goharbor/harbor/src/controller/project" "github.com/goharbor/harbor/src/controller/quota" "github.com/goharbor/harbor/src/controller/repository" + "github.com/goharbor/harbor/src/controller/retention" "github.com/goharbor/harbor/src/core/api" "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/lib" @@ -606,10 +620,10 @@ func getProjectQuotaSummary(ctx context.Context, p *project.Project, summary *mo summary.Quota = &models.ProjectSummaryQuota{} if hard, err := q.GetHard(); err == nil { - lib.JSONCopy(&summary.Quota.Hard, hard) + summary.Quota.Hard = hard } if used, err := q.GetUsed(); err == nil { - lib.JSONCopy(&summary.Quota.Used, used) + summary.Quota.Used = used } } diff --git a/src/server/v2.0/handler/quota.go b/src/server/v2.0/handler/quota.go new file mode 100644 index 000000000..9d4dc8923 --- /dev/null +++ b/src/server/v2.0/handler/quota.go @@ -0,0 +1,116 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "context" + + "github.com/go-openapi/runtime/middleware" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/controller/quota" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/server/v2.0/handler/model" + "github.com/goharbor/harbor/src/server/v2.0/models" + operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/quota" +) + +func newQuotaAPI() *quotaAPI { + return "aAPI{ + quotaCtl: quota.Ctl, + } +} + +type quotaAPI struct { + BaseAPI + quotaCtl quota.Controller +} + +func (qa *quotaAPI) GetQuota(ctx context.Context, params operation.GetQuotaParams) middleware.Responder { + if err := qa.RequireSystemAccess(ctx, rbac.ActionRead, rbac.ResourceQuota); err != nil { + return qa.SendError(ctx, err) + } + + quota, err := qa.quotaCtl.Get(ctx, params.ID, quota.WithReferenceObject()) + if err != nil { + return qa.SendError(ctx, err) + + } + return operation.NewGetQuotaOK().WithPayload(model.NewQuota(quota).ToSwagger(ctx)) +} + +func (qa *quotaAPI) ListQuotas(ctx context.Context, params operation.ListQuotasParams) middleware.Responder { + if err := qa.RequireSystemAccess(ctx, rbac.ActionList, rbac.ResourceQuota); err != nil { + return qa.SendError(ctx, err) + } + + query := &q.Query{ + Keywords: q.KeyWords{ + "reference": lib.StringValue(params.Reference), + "reference_id": lib.StringValue(params.ReferenceID), + }, + PageNumber: *params.Page, + PageSize: *params.PageSize, + Sorting: lib.StringValue(params.Sort), + } + + total, err := qa.quotaCtl.Count(ctx, query) + if err != nil { + return qa.SendError(ctx, err) + } + + quotas, err := qa.quotaCtl.List(ctx, query, quota.WithReferenceObject()) + if err != nil { + return qa.SendError(ctx, err) + } + + payload := make([]*models.Quota, len(quotas)) + for i, quota := range quotas { + payload[i] = model.NewQuota(quota).ToSwagger(ctx) + } + + return operation.NewListQuotasOK(). + WithXTotalCount(total). + WithLink(qa.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()). + WithPayload(payload) +} + +func (qa *quotaAPI) UpdateQuota(ctx context.Context, params operation.UpdateQuotaParams) middleware.Responder { + if err := qa.RequireSystemAccess(ctx, rbac.ActionUpdate, rbac.ResourceQuota); err != nil { + return qa.SendError(ctx, err) + } + + if params.Hard == nil || len(params.Hard.Hard) == 0 { + return qa.SendError(ctx, errors.BadRequestError(nil).WithMessage("hard required in body")) + } + + q, err := qa.quotaCtl.Get(ctx, params.ID) + if err != nil { + return qa.SendError(ctx, err) + } + + if err := quota.Validate(ctx, q.Reference, params.Hard.Hard); err != nil { + return qa.SendError(ctx, errors.BadRequestError(nil).WithMessage(err.Error())) + } + + q.SetHard(params.Hard.Hard) + + if err := qa.quotaCtl.Update(ctx, q); err != nil { + return qa.SendError(ctx, err) + } + + return operation.NewUpdateQuotaOK() +} diff --git a/src/server/v2.0/handler/quota_test.go b/src/server/v2.0/handler/quota_test.go new file mode 100644 index 000000000..13b1d4bf9 --- /dev/null +++ b/src/server/v2.0/handler/quota_test.go @@ -0,0 +1,283 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/pkg/quota" + "github.com/goharbor/harbor/src/pkg/quota/types" + "github.com/goharbor/harbor/src/server/v2.0/models" + "github.com/goharbor/harbor/src/server/v2.0/restapi" + quotatesting "github.com/goharbor/harbor/src/testing/controller/quota" + "github.com/goharbor/harbor/src/testing/mock" + htesting "github.com/goharbor/harbor/src/testing/server/v2.0/handler" + "github.com/stretchr/testify/suite" +) + +type QuotaTestSuite struct { + htesting.Suite + + quotaCtl *quotatesting.Controller + quota *quota.Quota +} + +func (suite *QuotaTestSuite) SetupSuite() { + suite.quota = "a.Quota{ + ID: 1, + Reference: "project", + ReferenceID: "1", + Hard: `{"storage": 100}`, + Used: `{"storage": 1000}`, + CreationTime: time.Now(), + UpdateTime: time.Now(), + } + + suite.quotaCtl = "atesting.Controller{} + + suite.Config = &restapi.Config{ + QuotaAPI: "aAPI{ + quotaCtl: suite.quotaCtl, + }, + } + + suite.Suite.SetupSuite() +} + +func (suite *QuotaTestSuite) TestAuthorization() { + newBody := func(body interface{}) io.Reader { + if body == nil { + return nil + } + + buf, err := json.Marshal(body) + suite.Require().NoError(err) + return bytes.NewBuffer(buf) + } + + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{"storage": 1000}, + } + + reqs := []struct { + method string + url string + body interface{} + }{ + {http.MethodGet, "/quotas/1", nil}, + {http.MethodGet, "/quotas", nil}, + {http.MethodPut, "/quotas/1", quota}, + } + + for _, req := range reqs { + { + // authorized required + suite.Security.On("IsAuthenticated").Return(false).Once() + + res, err := suite.DoReq(req.method, req.url, newBody(req.body)) + suite.NoError(err) + suite.Equal(401, res.StatusCode) + } + + { + // permission required + suite.Security.On("IsAuthenticated").Return(true).Once() + suite.Security.On("GetUsername").Return("username").Once() + suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(false).Once() + + res, err := suite.DoReq(req.method, req.url, newBody(req.body)) + suite.NoError(err) + suite.Equal(403, res.StatusCode) + } + } +} + +func (suite *QuotaTestSuite) TestGetQuota() { + times := 3 + suite.Security.On("IsAuthenticated").Return(true).Times(times) + suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Times(times) + + { + // get quota failed + mock.OnAnything(suite.quotaCtl, "Get").Return(nil, fmt.Errorf("failed to get quota")).Once() + + res, err := suite.Get("/quotas/1") + suite.NoError(err) + suite.Equal(500, res.StatusCode) + } + + { + // quota not found + mock.OnAnything(suite.quotaCtl, "Get").Return(nil, errors.NotFoundError(nil)).Once() + + var quota map[string]interface{} + res, err := suite.GetJSON("/quotas/1", "a) + suite.NoError(err) + suite.Equal(404, res.StatusCode) + } + + { + // quota found + mock.OnAnything(suite.quotaCtl, "Get").Return(suite.quota, nil).Once() + + var quota map[string]interface{} + res, err := suite.GetJSON("/quotas/1", "a) + suite.NoError(err) + suite.Equal(200, res.StatusCode) + suite.Equal(float64(1), quota["id"]) + } +} + +func (suite *QuotaTestSuite) TestListQuotas() { + times := 5 + suite.Security.On("IsAuthenticated").Return(true).Times(times) + suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Times(times) + + { + // list quotas failed + mock.OnAnything(suite.quotaCtl, "Count").Return(int64(0), fmt.Errorf("failed to count quotas")).Once() + + res, err := suite.Get("/quotas") + suite.NoError(err) + suite.Equal(500, res.StatusCode) + } + + { + // list quotas failed + mock.OnAnything(suite.quotaCtl, "Count").Return(int64(1), nil).Once() + mock.OnAnything(suite.quotaCtl, "List").Return(nil, fmt.Errorf("failed to list quotas")).Once() + + res, err := suite.Get("/quotas") + suite.NoError(err) + suite.Equal(500, res.StatusCode) + } + + { + // quotas not found + mock.OnAnything(suite.quotaCtl, "Count").Return(int64(0), nil).Once() + mock.OnAnything(suite.quotaCtl, "List").Return(nil, nil).Once() + + var quotas []interface{} + res, err := suite.GetJSON("/quotas", "as) + suite.NoError(err) + suite.Equal(200, res.StatusCode) + suite.Len(quotas, 0) + } + + { + // quotas found + mock.OnAnything(suite.quotaCtl, "Count").Return(int64(3), nil).Once() + mock.OnAnything(suite.quotaCtl, "List").Return([]*quota.Quota{suite.quota}, nil).Once() + + var quotas []interface{} + res, err := suite.GetJSON("/quotas?page_size=1&page=2", "as) + suite.NoError(err) + suite.Equal(200, res.StatusCode) + suite.Len(quotas, 1) + suite.Equal("3", res.Header.Get("X-Total-Count")) + suite.Contains(res.Header, "Link") + suite.Equal(`; rel="prev" , ; rel="next"`, res.Header.Get("Link")) + } +} + +func (suite *QuotaTestSuite) TestUpdateQuota() { + times := 6 + suite.Security.On("IsAuthenticated").Return(true).Times(times) + suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Times(times) + + { + // update quota no body + res, err := suite.Put("/quotas/1", nil) + suite.NoError(err) + suite.Equal(422, res.StatusCode) + } + + { + // update quota with empty hard + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{}, + } + + res, err := suite.PutJSON("/quotas/1", quota) + suite.NoError(err) + suite.Equal(400, res.StatusCode) + } + + { + // quota not found + mock.OnAnything(suite.quotaCtl, "Get").Return(nil, errors.NotFoundError(nil)).Once() + + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{"storage": 1000}, + } + + res, err := suite.PutJSON("/quotas/1", quota) + suite.NoError(err) + suite.Equal(404, res.StatusCode) + } + + { + // update quota + mock.OnAnything(suite.quotaCtl, "Get").Return(suite.quota, nil).Once() + mock.OnAnything(suite.quotaCtl, "Update").Return(nil).Once() + + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{"storage": 1000}, + } + + res, err := suite.PutJSON("/quotas/1", quota) + suite.NoError(err) + suite.Equal(200, res.StatusCode) + } + + { + // update quota failed + mock.OnAnything(suite.quotaCtl, "Get").Return(suite.quota, nil).Once() + mock.OnAnything(suite.quotaCtl, "Update").Return(fmt.Errorf("failed to update the quota")).Once() + + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{"storage": 1000}, + } + + res, err := suite.PutJSON("/quotas/1", quota) + suite.NoError(err) + suite.Equal(500, res.StatusCode) + } + + { + // resource not support + mock.OnAnything(suite.quotaCtl, "Get").Return(suite.quota, nil).Once() + mock.OnAnything(suite.quotaCtl, "Update").Return(nil).Once() + + quota := models.QuotaUpdateReq{ + Hard: types.ResourceList{"size": 1000}, + } + + res, err := suite.PutJSON("/quotas/1", quota) + suite.NoError(err) + suite.Equal(400, res.StatusCode) + } +} + +func TestQuotaTestSuite(t *testing.T) { + suite.Run(t, &QuotaTestSuite{}) +} diff --git a/src/server/v2.0/route/legacy.go b/src/server/v2.0/route/legacy.go index eaa324f96..de0e7ad0d 100755 --- a/src/server/v2.0/route/legacy.go +++ b/src/server/v2.0/route/legacy.go @@ -42,9 +42,6 @@ func registerLegacyRoutes() { beego.Router("/api/"+version+"/projects/:id([0-9]+)/metadatas/?:name", &api.MetadataAPI{}, "get:Get") beego.Router("/api/"+version+"/projects/:id([0-9]+)/metadatas/", &api.MetadataAPI{}, "post:Post") - beego.Router("/api/"+version+"/quotas", &api.QuotaAPI{}, "get:List") - beego.Router("/api/"+version+"/quotas/:id([0-9]+)", &api.QuotaAPI{}, "get:Get;put:Put") - beego.Router("/api/"+version+"/system/CVEAllowlist", &api.SysCVEAllowlistAPI{}, "get:Get;put:Put") beego.Router("/api/"+version+"/system/oidc/ping", &api.OIDCAPI{}, "post:Ping") diff --git a/src/testing/apitests/apilib/quota.go b/src/testing/apitests/apilib/quota.go deleted file mode 100644 index 288fb7918..000000000 --- a/src/testing/apitests/apilib/quota.go +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Harbor API - * - * These APIs provide services for manipulating Harbor project. - * - * OpenAPI spec version: 0.3.0 - * - * Generated by: https://github.com/swagger-api/swagger-codegen.git - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package apilib - -// QuotaQuery query for quota -type QuotaQuery struct { - Reference string `url:"reference,omitempty"` - ReferenceID string `url:"reference_id,omitempty"` - Page int64 `url:"page,omitempty"` - PageSize int64 `url:"page_size,omitempty"` -} - -// Quota ... -type Quota struct { - ID int `json:"id"` - Ref map[string]interface{} `json:"ref"` - Hard map[string]int64 `json:"hard"` - Used map[string]int64 `json:"used"` -} diff --git a/tests/apitests/python/library/base.py b/tests/apitests/python/library/base.py index b84efe092..42a1c0221 100644 --- a/tests/apitests/python/library/base.py +++ b/tests/apitests/python/library/base.py @@ -28,7 +28,7 @@ def get_endpoint(): def _create_client(server, credential, debug, api_type="products"): cfg = None - if api_type in ('projectv2', 'artifact', 'repository', 'scan', 'scanall', 'preheat', 'replication', 'robot', 'gc', 'retention'): + if api_type in ('projectv2', 'artifact', 'repository', 'scan', 'scanall', 'preheat', 'quota', 'replication', 'robot', 'gc', 'retention'): cfg = v2_swagger_client.Configuration() else: cfg = swagger_client.Configuration() @@ -56,6 +56,7 @@ def _create_client(server, credential, debug, api_type="products"): "projectv2": v2_swagger_client.ProjectApi(v2_swagger_client.ApiClient(cfg)), "artifact": v2_swagger_client.ArtifactApi(v2_swagger_client.ApiClient(cfg)), "preheat": v2_swagger_client.PreheatApi(v2_swagger_client.ApiClient(cfg)), + "quota": v2_swagger_client.QuotaApi(v2_swagger_client.ApiClient(cfg)), "repository": v2_swagger_client.RepositoryApi(v2_swagger_client.ApiClient(cfg)), "scan": v2_swagger_client.ScanApi(v2_swagger_client.ApiClient(cfg)), "scanall": v2_swagger_client.ScanAllApi(v2_swagger_client.ApiClient(cfg)), diff --git a/tests/apitests/python/library/system.py b/tests/apitests/python/library/system.py index fd60303ac..efbaade52 100644 --- a/tests/apitests/python/library/system.py +++ b/tests/apitests/python/library/system.py @@ -137,7 +137,7 @@ class System(base.Base): params['reference'] = reference params['reference_id'] = reference_id - client = self._get_client(**kwargs) - data, status_code, _ = client.quotas_get_with_http_info(**params) + client = self._get_client(api_type='quota', **kwargs) + data, status_code, _ = client.list_quotas_with_http_info(**params) base._assert_status_code(200, status_code) return data