diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c36b60308..682a59128 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -995,6 +995,86 @@ paths: description: Forbidden. '404': description: Repository not found. + '/repositories/{repo_name}/labels': + get: + summary: Get labels of a repository. + description: | + Get labels of a repository specified by the repo_name. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + tags: + - Products + responses: + '200': + description: Successfully. + schema: + type: array + items: + $ref: '#/definitions/Label' + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have read permisson for the repository to perform the action. + '404': + description: Repository not found. + post: + summary: Add a label to the repository. + description: | + Add a label to the repository. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + - name: label + in: body + description: Only the ID property is required. + required: true + schema: + $ref: '#/definitions/Label' + tags: + - Products + responses: + '200': + description: Successfully. + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have write permisson for the repository to perform the action. + '404': + description: Resource not found. + '/repositories/{repo_name}/labels/{label_id}': + delete: + summary: Delete label from the repository. + description: | + Delete the label from the repository specified by the repo_name. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + - name: label_id + in: path + type: integer + required: true + description: The ID of label. + tags: + - Products + responses: + '200': + description: Successfully. + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have write permisson for the repository to perform the action. + '404': + description: Resource not found. '/repositories/{repo_name}/tags/{tag}': get: summary: Get the tag of the repository. @@ -1075,6 +1155,101 @@ paths: $ref: '#/definitions/DetailedTag' '500': description: Unexpected internal errors. + '/repositories/{repo_name}/tags/{tag}/labels': + get: + summary: Get labels of an image. + description: | + Get labels of an image specified by the repo_name and tag. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + - name: tag + in: path + type: string + required: true + description: The tag of the image. + tags: + - Products + responses: + '200': + description: Successfully. + schema: + type: array + items: + $ref: '#/definitions/Label' + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have read permisson for the image to perform the action. + '404': + description: Resource not found. + post: + summary: Add a label to image. + description: | + Add a label to the image. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + - name: tag + in: path + type: string + required: true + description: The tag of the image. + - name: label + in: body + description: Only the ID property is required. + required: true + schema: + $ref: '#/definitions/Label' + tags: + - Products + responses: + '200': + description: Successfully. + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have write permisson for the image to perform the action. + '404': + description: Resource not found. + '/repositories/{repo_name}/tags/{tag}/labels/{label_id}': + delete: + summary: Delete label from the image. + description: | + Delete the label from the image specified by the repo_name and tag. + parameters: + - name: repo_name + in: path + type: string + required: true + description: The name of repository. + - name: tag + in: path + type: string + required: true + description: The tag of the image. + - name: label_id + in: path + type: integer + required: true + description: The ID of label. + tags: + - Products + responses: + '200': + description: Successfully. + '401': + description: Unauthorized. + '403': + description: Forbidden. User should have write permisson for the image to perform the action. + '404': + description: Resource not found. '/repositories/{repo_name}/tags/{tag}/manifest': get: summary: Get manifests of a relevant repository. @@ -3039,6 +3214,11 @@ definitions: type: array items: $ref: '#/definitions/ComponentOverviewEntry' + labels: + type: array + description: The label list. + items: + $ref: '#/definitions/Label' ComponentOverviewEntry: type: object properties: @@ -3072,6 +3252,11 @@ definitions: tags_count: type: integer description: The tags count of repository. + labels: + type: array + description: The label list. + items: + $ref: '#/definitions/Label' creation_time: type: string description: The creation time of repository. diff --git a/make/photon/db/registry.sql b/make/photon/db/registry.sql index 02c1fb6ca..02e96d663 100644 --- a/make/photon/db/registry.sql +++ b/make/photon/db/registry.sql @@ -272,6 +272,23 @@ create table harbor_label ( CONSTRAINT unique_name_and_scope UNIQUE (name,scope) ); +create table harbor_resource_label ( + id int NOT NULL AUTO_INCREMENT, + label_id int NOT NULL, +# the resource_id is the ID of project when the resource_type is p +# the resource_id is the ID of repository when the resource_type is r +# the resource_id is the name of image when the resource_type is i + resource_id varchar(256) NOT NULL, +# 'p' for project +# 'r' for repository +# 'i' for image + resource_type char(1) NOT NULL, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP, + PRIMARY KEY(id), + CONSTRAINT unique_label_resource UNIQUE (label_id,resource_id, resource_type) + ); + CREATE TABLE IF NOT EXISTS `alembic_version` ( `version_num` varchar(32) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/make/photon/db/registry_sqlite.sql b/make/photon/db/registry_sqlite.sql index c5dc67567..5d045edb0 100644 --- a/make/photon/db/registry_sqlite.sql +++ b/make/photon/db/registry_sqlite.sql @@ -261,6 +261,26 @@ create table harbor_label ( UNIQUE(name, scope) ); +create table harbor_resource_label ( + id INTEGER PRIMARY KEY, + label_id int NOT NULL, +/* + the resource_id is the ID of project when the resource_type is p + the resource_id is the ID of repository when the resource_type is r + the resource_id is the name of image when the resource_type is i +*/ + resource_id varchar(256) NOT NULL, +/* + 'p' for project + 'r' for repository + 'i' for image +*/ + resource_type char(1) NOT NULL, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP, + UNIQUE (label_id,resource_id, resource_type) + ); + create table alembic_version ( version_num varchar(32) NOT NULL ); diff --git a/src/common/dao/resource_label.go b/src/common/dao/resource_label.go new file mode 100644 index 000000000..f89bb27a5 --- /dev/null +++ b/src/common/dao/resource_label.go @@ -0,0 +1,74 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// 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 dao + +import ( + "time" + + "github.com/astaxie/beego/orm" + "github.com/vmware/harbor/src/common/models" +) + +// AddResourceLabel add a label to a resource +func AddResourceLabel(rl *models.ResourceLabel) (int64, error) { + now := time.Now() + rl.CreationTime = now + rl.UpdateTime = now + return GetOrmer().Insert(rl) +} + +// GetResourceLabel specified by ID +func GetResourceLabel(rType, rID string, labelID int64) (*models.ResourceLabel, error) { + rl := &models.ResourceLabel{ + ResourceType: rType, + ResourceID: rID, + LabelID: labelID, + } + if err := GetOrmer().Read(rl, "ResourceType", "ResourceID", "LabelID"); err != nil { + if err == orm.ErrNoRows { + return nil, nil + } + return nil, err + } + + return rl, nil +} + +// GetLabelsOfResource returns the label list of the resource +func GetLabelsOfResource(rType, rID string) ([]*models.Label, error) { + sql := `select l.id, l.name, l.description, l.color, l.scope, l.project_id, l.creation_time, l.update_time + from harbor_resource_label rl + join harbor_label l on rl.label_id=l.id + where rl.resource_type = ? and rl.resource_id = ?` + labels := []*models.Label{} + _, err := GetOrmer().Raw(sql, rType, rID).QueryRows(&labels) + return labels, err +} + +// DeleteResourceLabel ... +func DeleteResourceLabel(id int64) error { + _, err := GetOrmer().Delete(&models.ResourceLabel{ + ID: id, + }) + return err +} + +// DeleteLabelsOfResource removes all labels of resource specified by rType and rID +func DeleteLabelsOfResource(rType, rID string) error { + _, err := GetOrmer().QueryTable(&models.ResourceLabel{}). + Filter("ResourceType", rType). + Filter("ResourceID", rID).Delete() + return err +} diff --git a/src/common/dao/resource_label_test.go b/src/common/dao/resource_label_test.go new file mode 100644 index 000000000..33259e1fd --- /dev/null +++ b/src/common/dao/resource_label_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// 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 dao + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vmware/harbor/src/common" + "github.com/vmware/harbor/src/common/models" +) + +func TestMethodsOfResourceLabel(t *testing.T) { + labelID, err := AddLabel(&models.Label{ + Name: "test_label", + Level: common.LabelLevelUser, + Scope: common.LabelScopeGlobal, + }) + require.Nil(t, err) + defer DeleteLabel(labelID) + + resourceID := "1" + resourceType := common.ResourceTypeRepository + + // add + rl := &models.ResourceLabel{ + LabelID: labelID, + ResourceType: resourceType, + ResourceID: resourceID, + } + + id, err := AddResourceLabel(rl) + require.Nil(t, err) + + // get + r, err := GetResourceLabel(resourceType, resourceID, labelID) + require.Nil(t, err) + assert.Equal(t, id, r.ID) + + // get by resource + labels, err := GetLabelsOfResource(resourceType, resourceID) + require.Nil(t, err) + require.Equal(t, 1, len(labels)) + assert.Equal(t, id, r.ID) + + // delete + err = DeleteResourceLabel(id) + require.Nil(t, err) + labels, err = GetLabelsOfResource(resourceType, resourceID) + require.Nil(t, err) + require.Equal(t, 0, len(labels)) + + // delete by resource + id, err = AddResourceLabel(rl) + require.Nil(t, err) + err = DeleteLabelsOfResource(resourceType, resourceID) + require.Nil(t, err) + labels, err = GetLabelsOfResource(resourceType, resourceID) + require.Nil(t, err) + require.Equal(t, 0, len(labels)) +} diff --git a/src/common/models/base.go b/src/common/models/base.go index 89f201432..a96e3a72e 100644 --- a/src/common/models/base.go +++ b/src/common/models/base.go @@ -33,5 +33,6 @@ func init() { new(WatchItem), new(ProjectMetadata), new(ConfigEntry), - new(Label)) + new(Label), + new(ResourceLabel)) } diff --git a/src/common/models/label.go b/src/common/models/label.go index 90d3f278f..6d949b81c 100644 --- a/src/common/models/label.go +++ b/src/common/models/label.go @@ -35,7 +35,7 @@ type Label struct { UpdateTime time.Time `orm:"column(update_time)" json:"update_time"` } -//TableName ... +// TableName ... func (l *Label) TableName() string { return "harbor_label" } @@ -65,30 +65,17 @@ func (l *Label) Valid(v *validation.Validation) { } } -/* +// ResourceLabel records the relationship between resource and label type ResourceLabel struct { - ID int64 `orm:"pk;auto;column(id)" json:"id"` - LabelID int64 `orm:"column(label_id)" json:"label_id"` - ResourceID string `orm:"column(resource_id)" json:"resource_id"` - ResourceType rune `orm:"column(resource_type)" json:"resource_type"` - CreationTime time.Time `orm:"column(creation_time)" json:"creation_time"` - UpdateTime time.Time `orm:"column(update_time)" json:"update_time"` + ID int64 `orm:"pk;auto;column(id)"` + LabelID int64 `orm:"column(label_id)"` + ResourceID string `orm:"column(resource_id)"` + ResourceType string `orm:"column(resource_type)"` + CreationTime time.Time `orm:"column(creation_time)"` + UpdateTime time.Time `orm:"column(update_time)"` } - -// Valid ... -func (r *ResourceLabel) Valid(v *validation.Validation) { - if r.LabelID <= 0 { - v.SetError("label_id", fmt.Sprintf("invalid: %d", r.LabelID)) - } - // TODO - //if r.ResourceID <= 0 { - // v.SetError("resource_id", fmt.Sprintf("invalid: %v", r.ResourceID)) - //} - if r.ResourceType != common.ResourceTypeProject && - r.ResourceType != common.ResourceTypeRepository && - r.ResourceType != common.ResourceTypeImage { - v.SetError("resource_type", fmt.Sprintf("invalid: %d", r.ResourceType)) - } +// TableName ... +func (r *ResourceLabel) TableName() string { + return "harbor_resource_label" } -*/ diff --git a/src/ui/api/harborapi_test.go b/src/ui/api/harborapi_test.go index b63ce193f..f108aed23 100644 --- a/src/ui/api/harborapi_test.go +++ b/src/ui/api/harborapi_test.go @@ -111,6 +111,10 @@ func init() { beego.Router("/api/users/?:id", &UserAPI{}) beego.Router("/api/logs", &LogAPI{}) beego.Router("/api/repositories/*", &RepositoryAPI{}, "put:Put") + beego.Router("/api/repositories/*/labels", &RepositoryLabelAPI{}, "get:GetOfRepository;post:AddToRepository") + beego.Router("/api/repositories/*/labels/:id([0-9]+", &RepositoryLabelAPI{}, "delete:RemoveFromRepository") + beego.Router("/api/repositories/*/tags/:tag/labels", &RepositoryLabelAPI{}, "get:GetOfImage;post:AddToImage") + beego.Router("/api/repositories/*/tags/:tag/labels/:id([0-9]+", &RepositoryLabelAPI{}, "delete:RemoveFromImage") beego.Router("/api/repositories/*/tags/:tag", &RepositoryAPI{}, "delete:Delete;get:GetTag") beego.Router("/api/repositories/*/tags", &RepositoryAPI{}, "get:GetTags") beego.Router("/api/repositories/*/tags/:tag/manifest", &RepositoryAPI{}, "get:GetManifests") diff --git a/src/ui/api/repository.go b/src/ui/api/repository.go index c61f2c20f..b57786ff6 100644 --- a/src/ui/api/repository.go +++ b/src/ui/api/repository.go @@ -25,6 +25,7 @@ import ( "github.com/docker/distribution/manifest/schema1" "github.com/docker/distribution/manifest/schema2" + "github.com/vmware/harbor/src/common" "github.com/vmware/harbor/src/common/dao" "github.com/vmware/harbor/src/common/models" "github.com/vmware/harbor/src/common/notifier" @@ -47,15 +48,16 @@ type RepositoryAPI struct { } type repoResp struct { - ID int64 `json:"id"` - Name string `json:"name"` - ProjectID int64 `json:"project_id"` - Description string `json:"description"` - PullCount int64 `json:"pull_count"` - StarCount int64 `json:"star_count"` - TagsCount int64 `json:"tags_count"` - CreationTime time.Time `json:"creation_time"` - UpdateTime time.Time `json:"update_time"` + ID int64 `json:"id"` + Name string `json:"name"` + ProjectID int64 `json:"project_id"` + Description string `json:"description"` + PullCount int64 `json:"pull_count"` + StarCount int64 `json:"star_count"` + TagsCount int64 `json:"tags_count"` + Labels []*models.Label `json:"labels"` + CreationTime time.Time `json:"creation_time"` + UpdateTime time.Time `json:"update_time"` } type tagDetail struct { @@ -78,6 +80,7 @@ type tagResp struct { tagDetail Signature *notary.Target `json:"signature"` ScanOverview *models.ImgScanOverview `json:"scan_overview,omitempty"` + Labels []*models.Label `json:"labels"` } type manifestResp struct { @@ -145,10 +148,10 @@ func getRepositories(projectID int64, keyword string, return nil, err } - return populateTagsCount(repositories) + return assembleRepos(repositories) } -func populateTagsCount(repositories []*models.RepoRecord) ([]*repoResp, error) { +func assembleRepos(repositories []*models.RepoRecord) ([]*repoResp, error) { result := []*repoResp{} for _, repository := range repositories { repo := &repoResp{ @@ -167,6 +170,15 @@ func populateTagsCount(repositories []*models.RepoRecord) ([]*repoResp, error) { return nil, err } repo.TagsCount = int64(len(tags)) + + labels, err := dao.GetLabelsOfResource(common.ResourceTypeRepository, + strconv.FormatInt(repository.RepositoryID, 10)) + if err != nil { + log.Errorf("failed to get labels of repository %s: %v", repository.Name, err) + } else { + repo.Labels = labels + } + result = append(result, repo) } return result, nil @@ -252,6 +264,11 @@ func (ra *RepositoryAPI) Delete() { } for _, t := range tags { + image := fmt.Sprintf("%s:%s", repoName, t) + if err = dao.DeleteLabelsOfResource(common.ResourceTypeImage, image); err != nil { + ra.HandleInternalServerError(fmt.Sprintf("failed to delete labels of image %s: %v", image, err)) + return + } if err = rc.DeleteTag(t); err != nil { if regErr, ok := err.(*registry_error.HTTPError); ok { if regErr.StatusCode == http.StatusNotFound { @@ -296,6 +313,22 @@ func (ra *RepositoryAPI) Delete() { ra.CustomAbort(http.StatusInternalServerError, "") } if !exist { + repository, err := dao.GetRepositoryByName(repoName) + if err != nil { + ra.HandleInternalServerError(fmt.Sprintf("failed to get repository %s: %v", repoName, err)) + return + } + if repository == nil { + ra.HandleNotFound(fmt.Sprintf("repository %s not found", repoName)) + return + } + + if err = dao.DeleteLabelsOfResource(common.ResourceTypeRepository, + strconv.FormatInt(repository.RepositoryID, 10)); err != nil { + ra.HandleInternalServerError(fmt.Sprintf("failed to delete labels of repository %s: %v", + repoName, err)) + return + } if err = dao.DeleteRepository(repoName); err != nil { log.Errorf("failed to delete repository %s: %v", repoName, err) ra.CustomAbort(http.StatusInternalServerError, "") @@ -343,7 +376,7 @@ func (ra *RepositoryAPI) GetTag() { return } - result := assemble(client, repository, []string{tag}, + result := assembleTags(client, repository, []string{tag}, ra.SecurityCtx.GetUsername()) ra.Data["json"] = result[0] ra.ServeJSON() @@ -387,13 +420,13 @@ func (ra *RepositoryAPI) GetTags() { return } - ra.Data["json"] = assemble(client, repoName, tags, ra.SecurityCtx.GetUsername()) + ra.Data["json"] = assembleTags(client, repoName, tags, ra.SecurityCtx.GetUsername()) ra.ServeJSON() } // get config, signature and scan overview and assemble them into one // struct for each tag in tags -func assemble(client *registry.Repository, repository string, +func assembleTags(client *registry.Repository, repository string, tags []string, username string) []*tagResp { var err error @@ -435,6 +468,15 @@ func assemble(client *registry.Repository, repository string, } } + // labels + image := fmt.Sprintf("%s:%s", repository, t) + labels, err := dao.GetLabelsOfResource(common.ResourceTypeImage, image) + if err != nil { + log.Errorf("failed to get labels of image %s: %v", image, err) + } else { + item.Labels = labels + } + result = append(result, item) } @@ -633,7 +675,7 @@ func (ra *RepositoryAPI) GetTopRepos() { ra.CustomAbort(http.StatusInternalServerError, "internal server error") } - result, err := populateTagsCount(repos) + result, err := assembleRepos(repos) if err != nil { log.Errorf("failed to popultate tags count to repositories: %v", err) ra.CustomAbort(http.StatusInternalServerError, "internal server error") diff --git a/src/ui/api/repository_label.go b/src/ui/api/repository_label.go new file mode 100644 index 000000000..95e92cdfc --- /dev/null +++ b/src/ui/api/repository_label.go @@ -0,0 +1,248 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// 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" + "net/http" + "strconv" + + "github.com/vmware/harbor/src/common" + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/models" + "github.com/vmware/harbor/src/common/utils" + uiutils "github.com/vmware/harbor/src/ui/utils" +) + +// RepositoryLabelAPI handles requests for adding/removing label to/from repositories and images +type RepositoryLabelAPI struct { + BaseController + repository *models.RepoRecord + tag string + label *models.Label +} + +// Prepare ... +func (r *RepositoryLabelAPI) Prepare() { + r.BaseController.Prepare() + if !r.SecurityCtx.IsAuthenticated() { + r.HandleUnauthorized() + return + } + + repository := r.GetString(":splat") + project, _ := utils.ParseRepository(repository) + if !r.SecurityCtx.HasWritePerm(project) { + r.HandleForbidden(r.SecurityCtx.GetUsername()) + return + } + + repo, err := dao.GetRepositoryByName(repository) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get repository %s: %v", + repository, err)) + return + } + if repo == nil { + r.HandleNotFound(fmt.Sprintf("repository %s not found", repository)) + return + } + r.repository = repo + + tag := r.GetString(":tag") + if len(tag) > 0 { + exist, err := imageExist(r.SecurityCtx.GetUsername(), repository, tag) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to check the existence of image %s:%s: %v", + repository, tag, err)) + return + } + if !exist { + r.HandleNotFound(fmt.Sprintf("image %s:%s not found", repository, tag)) + return + } + r.tag = tag + } + + if r.Ctx.Request.Method == http.MethodPost { + l := &models.Label{} + r.DecodeJSONReq(l) + + label, err := dao.GetLabel(l.ID) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get label %d: %v", l.ID, err)) + return + } + + if label == nil { + r.HandleNotFound(fmt.Sprintf("label %d not found", l.ID)) + return + } + + if label.Level != common.LabelLevelUser { + r.HandleBadRequest("only user level labels can be used") + return + } + + if label.Scope == common.LabelScopeProject { + p, err := r.ProjectMgr.Get(project) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get project %s: %v", project, err)) + return + } + + if p.ProjectID != label.ProjectID { + r.HandleBadRequest("can not add labels which don't belong to the project to the resources under the project") + return + } + } + r.label = label + + return + } + + if r.Ctx.Request.Method == http.MethodDelete { + labelID, err := r.GetInt64FromPath(":id") + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get ID parameter from path: %v", err)) + return + } + + label, err := dao.GetLabel(labelID) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get label %d: %v", labelID, err)) + return + } + + if label == nil { + r.HandleNotFound(fmt.Sprintf("label %d not found", labelID)) + return + } + r.label = label + } +} + +// GetOfImage returns labels of an image +func (r *RepositoryLabelAPI) GetOfImage() { + r.getLabels(common.ResourceTypeImage, fmt.Sprintf("%s:%s", r.repository.Name, r.tag)) +} + +// AddToImage adds the label to an image +func (r *RepositoryLabelAPI) AddToImage() { + rl := &models.ResourceLabel{ + LabelID: r.label.ID, + ResourceType: common.ResourceTypeImage, + ResourceID: fmt.Sprintf("%s:%s", r.repository.Name, r.tag), + } + r.addLabel(rl) +} + +// RemoveFromImage removes the label from an image +func (r *RepositoryLabelAPI) RemoveFromImage() { + rl := &models.ResourceLabel{ + LabelID: r.label.ID, + ResourceType: common.ResourceTypeImage, + ResourceID: fmt.Sprintf("%s:%s", r.repository.Name, r.tag), + } + r.removeLabel(rl) +} + +// GetOfRepository returns labels of a repository +func (r *RepositoryLabelAPI) GetOfRepository() { + r.getLabels(common.ResourceTypeRepository, strconv.FormatInt(r.repository.RepositoryID, 10)) +} + +// AddToRepository adds the label to a repository +func (r *RepositoryLabelAPI) AddToRepository() { + rl := &models.ResourceLabel{ + LabelID: r.label.ID, + ResourceType: common.ResourceTypeRepository, + ResourceID: strconv.FormatInt(r.repository.RepositoryID, 10), + } + r.addLabel(rl) +} + +// RemoveFromRepository removes the label from a repository +func (r *RepositoryLabelAPI) RemoveFromRepository() { + rl := &models.ResourceLabel{ + LabelID: r.label.ID, + ResourceType: common.ResourceTypeRepository, + ResourceID: strconv.FormatInt(r.repository.RepositoryID, 10), + } + r.removeLabel(rl) +} + +func (r *RepositoryLabelAPI) getLabels(rType, rID string) { + labels, err := dao.GetLabelsOfResource(rType, rID) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get labels of resource %s %s: %v", + rType, rID, err)) + return + } + r.Data["json"] = labels + r.ServeJSON() +} + +func (r *RepositoryLabelAPI) addLabel(rl *models.ResourceLabel) { + rlabel, err := dao.GetResourceLabel(rl.ResourceType, rl.ResourceID, rl.LabelID) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to check the existence of label %d for resource %s %s: %v", + rl.LabelID, rl.ResourceType, rl.ResourceID, err)) + return + } + + if rlabel != nil { + r.HandleConflict() + return + } + if _, err := dao.AddResourceLabel(rl); err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to add label %d to resource %s %s: %v", + rl.LabelID, rl.ResourceType, rl.ResourceID, err)) + return + } + + // return the ID of label and return status code 200 rather than 201 as the label is not created + r.Redirect(http.StatusOK, strconv.FormatInt(rl.LabelID, 10)) +} + +func (r *RepositoryLabelAPI) removeLabel(rl *models.ResourceLabel) { + rlabel, err := dao.GetResourceLabel(rl.ResourceType, rl.ResourceID, rl.LabelID) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to check the existence of label %d for resource %s %s: %v", + rl.LabelID, rl.ResourceType, rl.ResourceID, err)) + return + } + + if rlabel == nil { + r.HandleNotFound(fmt.Sprintf("label %d of resource %s %s not found", + rl.LabelID, rl.ResourceType, rl.ResourceID)) + return + } + if err = dao.DeleteResourceLabel(rlabel.ID); err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to delete resource label record %d: %v", + rlabel.ID, err)) + return + } +} + +func imageExist(username, repository, tag string) (bool, error) { + client, err := uiutils.NewRepositoryClientForUI(username, repository) + if err != nil { + return false, err + } + + _, exist, err := client.ManifestExist(tag) + return exist, err +} diff --git a/src/ui/api/repository_label_test.go b/src/ui/api/repository_label_test.go new file mode 100644 index 000000000..30604d6fc --- /dev/null +++ b/src/ui/api/repository_label_test.go @@ -0,0 +1,253 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// 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" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vmware/harbor/src/common" + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/models" +) + +var ( + resourceLabelAPIBasePath = "/api/repositories" + repository = "library/hello-world" + tag = "latest" + proLibraryLabelID int64 +) + +func TestAddToImage(t *testing.T) { + sysLevelLabelID, err := dao.AddLabel(&models.Label{ + Name: "sys_level_label", + Level: common.LabelLevelSystem, + }) + require.Nil(t, err) + defer dao.DeleteLabel(sysLevelLabelID) + + proTestLabelID, err := dao.AddLabel(&models.Label{ + Name: "pro_test_label", + Level: common.LabelLevelUser, + Scope: common.LabelScopeProject, + ProjectID: 100, + }) + require.Nil(t, err) + defer dao.DeleteLabel(proTestLabelID) + + proLibraryLabelID, err = dao.AddLabel(&models.Label{ + Name: "pro_library_label", + Level: common.LabelLevelUser, + Scope: common.LabelScopeProject, + ProjectID: 1, + }) + require.Nil(t, err) + + cases := []*codeCheckingCase{ + // 401 + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + }, + code: http.StatusUnauthorized, + }, + // 403 + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + credential: projGuest, + }, + code: http.StatusForbidden, + }, + // 404 repository doesn't exist + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/library/non-exist-repo/tags/%s/labels", resourceLabelAPIBasePath, tag), + method: http.MethodPost, + credential: projDeveloper, + }, + code: http.StatusNotFound, + }, + // 404 image doesn't exist + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/non-exist-tag/labels", resourceLabelAPIBasePath, repository), + method: http.MethodPost, + credential: projDeveloper, + }, + code: http.StatusNotFound, + }, + // 400 + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, repository, tag), + method: http.MethodPost, + credential: projDeveloper, + }, + code: http.StatusBadRequest, + }, + // 404 label doesn't exist + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + credential: projDeveloper, + bodyJSON: struct { + ID int64 + }{ + ID: 1000, + }, + }, + code: http.StatusNotFound, + }, + // 400 system level label + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + credential: projDeveloper, + bodyJSON: struct { + ID int64 + }{ + ID: sysLevelLabelID, + }, + }, + code: http.StatusBadRequest, + }, + // 400 try to add the label of project1 to the image under project2 + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + credential: projDeveloper, + bodyJSON: struct { + ID int64 + }{ + ID: proTestLabelID, + }, + }, + code: http.StatusBadRequest, + }, + // 200 + &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodPost, + credential: projDeveloper, + bodyJSON: struct { + ID int64 + }{ + ID: proLibraryLabelID, + }, + }, + code: http.StatusOK, + }, + } + runCodeCheckingCases(t, cases...) +} + +func TestGetOfImage(t *testing.T) { + labels := []*models.Label{} + err := handleAndParse(&testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, repository, tag), + method: http.MethodGet, + credential: projDeveloper, + }, &labels) + require.Nil(t, err) + require.Equal(t, 1, len(labels)) + assert.Equal(t, proLibraryLabelID, labels[0].ID) +} + +func TestRemoveFromImage(t *testing.T) { + runCodeCheckingCases(t, &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels/%d", resourceLabelAPIBasePath, + repository, tag, proLibraryLabelID), + method: http.MethodDelete, + credential: projDeveloper, + }, + code: http.StatusOK, + }) + + labels := []*models.Label{} + err := handleAndParse(&testingRequest{ + url: fmt.Sprintf("%s/%s/tags/%s/labels", resourceLabelAPIBasePath, + repository, tag), + method: http.MethodGet, + credential: projDeveloper, + }, &labels) + require.Nil(t, err) + require.Equal(t, 0, len(labels)) +} + +func TestAddToRepository(t *testing.T) { + runCodeCheckingCases(t, &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/labels", resourceLabelAPIBasePath, repository), + method: http.MethodPost, + bodyJSON: struct { + ID int64 + }{ + ID: proLibraryLabelID, + }, + credential: projDeveloper, + }, + code: http.StatusOK, + }) +} + +func TestGetOfRepository(t *testing.T) { + labels := []*models.Label{} + err := handleAndParse(&testingRequest{ + url: fmt.Sprintf("%s/%s/labels", resourceLabelAPIBasePath, repository), + method: http.MethodGet, + credential: projDeveloper, + }, &labels) + require.Nil(t, err) + require.Equal(t, 1, len(labels)) + assert.Equal(t, proLibraryLabelID, labels[0].ID) +} + +func TestRemoveFromRepository(t *testing.T) { + runCodeCheckingCases(t, &codeCheckingCase{ + request: &testingRequest{ + url: fmt.Sprintf("%s/%s/labels/%d", resourceLabelAPIBasePath, + repository, proLibraryLabelID), + method: http.MethodDelete, + credential: projDeveloper, + }, + code: http.StatusOK, + }) + + labels := []*models.Label{} + err := handleAndParse(&testingRequest{ + url: fmt.Sprintf("%s/%s/labels", resourceLabelAPIBasePath, repository), + method: http.MethodGet, + credential: projDeveloper, + }, &labels) + require.Nil(t, err) + require.Equal(t, 0, len(labels)) +} diff --git a/src/ui/router.go b/src/ui/router.go index bcdc536ec..d6a722d6a 100644 --- a/src/ui/router.go +++ b/src/ui/router.go @@ -70,7 +70,11 @@ func initRouters() { beego.Router("/api/repositories", &api.RepositoryAPI{}, "get:Get") beego.Router("/api/repositories/scanAll", &api.RepositoryAPI{}, "post:ScanAll") beego.Router("/api/repositories/*", &api.RepositoryAPI{}, "delete:Delete;put:Put") + beego.Router("/api/repositories/*/labels", &api.RepositoryLabelAPI{}, "get:GetOfRepository;post:AddToRepository") + beego.Router("/api/repositories/*/labels/:id([0-9]+)", &api.RepositoryLabelAPI{}, "delete:RemoveFromRepository") beego.Router("/api/repositories/*/tags/:tag", &api.RepositoryAPI{}, "delete:Delete;get:GetTag") + beego.Router("/api/repositories/*/tags/:tag/labels", &api.RepositoryLabelAPI{}, "get:GetOfImage;post:AddToImage") + beego.Router("/api/repositories/*/tags/:tag/labels/:id([0-9]+)", &api.RepositoryLabelAPI{}, "delete:RemoveFromImage") beego.Router("/api/repositories/*/tags", &api.RepositoryAPI{}, "get:GetTags") beego.Router("/api/repositories/*/tags/:tag/scan", &api.RepositoryAPI{}, "post:ScanImage") beego.Router("/api/repositories/*/tags/:tag/vulnerability/details", &api.RepositoryAPI{}, "Get:VulnerabilityDetails") @@ -95,7 +99,7 @@ func initRouters() { beego.Router("/api/statistics", &api.StatisticAPI{}) beego.Router("/api/replications", &api.ReplicationAPI{}) beego.Router("/api/labels", &api.LabelAPI{}, "post:Post;get:List") - beego.Router("/api/labels/:id([0-9]+", &api.LabelAPI{}, "get:Get;put:Put;delete:Delete") + beego.Router("/api/labels/:id([0-9]+)", &api.LabelAPI{}, "get:Get;put:Put;delete:Delete") beego.Router("/api/systeminfo", &api.SystemInfoAPI{}, "get:GetGeneralInfo") beego.Router("/api/systeminfo/volumes", &api.SystemInfoAPI{}, "get:GetVolumeInfo") diff --git a/tools/migration/changelog.md b/tools/migration/changelog.md index 9dff157c3..85406a446 100644 --- a/tools/migration/changelog.md +++ b/tools/migration/changelog.md @@ -68,4 +68,5 @@ Changelog for harbor database schema ## 1.5.0 - - create table `harbor_label` \ No newline at end of file + - create table `harbor_label` + - create table `harbor_resource_label` \ No newline at end of file