diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4a83a1636..c36b60308 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1639,6 +1639,164 @@ paths: project and target. '500': description: Unexpected internal errors. + /labels: + get: + summary: List labels according to the query strings. + description: > + This endpoint let user list labels by name, scope and project_id + parameters: + - name: name + in: query + type: string + required: false + description: The label name. + - name: scope + in: query + type: string + required: true + description: The label scope. Valid values are g and p. g for global labels and p for project labels. + - name: project_id + in: query + type: integer + format: int64 + required: false + description: Relevant project ID, required when scope is p. + - name: page + in: query + type: integer + format: int32 + required: false + description: The page nubmer. + - name: page_size + in: query + type: integer + format: int32 + required: false + description: The size of per page. + tags: + - Products + responses: + '200': + description: Get successfully. + schema: + type: array + items: + $ref: '#/definitions/Label' + '400': + description: Invalid parameters. + '401': + description: User need to log in first. + '500': + description: Unexpected internal errors. + post: + summary: Post creates a label + description: > + This endpoint let user creates a label. + parameters: + - name: label + in: body + description: The json object of label. + required: true + schema: + $ref: '#/definitions/Label' + tags: + - Products + responses: + '201': + description: Create successfully. + '400': + description: Invalid parameters. + '401': + description: User need to log in first. + '409': + description: >- + Label with the same name and same scope already exists. + '415': + $ref: '#/responses/UnsupportedMediaType' + '500': + description: Unexpected internal errors. + '/labels/{id}': + get: + summary: Get the label specified by ID. + description: | + This endpoint let user get the label by specific ID. + parameters: + - name: id + in: path + type: integer + format: int64 + required: true + description: Label ID + tags: + - Products + responses: + '200': + description: Get successfully. + schema: + $ref: '#/definitions/Label' + '401': + description: User need to log in first. + '404': + description: The resource does not exist. + '500': + description: Unexpected internal errors. + put: + summary: Update the label properties. + description: > + This endpoint let user update label properties. + parameters: + - name: id + in: path + type: integer + format: int64 + required: true + description: Label ID + - name: label + in: body + description: The updated label json object. + required: true + schema: + $ref: '#/definitions/Label' + tags: + - Products + responses: + '200': + description: Update successfully. + '400': + description: Invalid parameters. + '401': + description: User need to log in first. + '404': + description: The resource does not exist. + '409': + description: >- + The label with the same name already exists. + '500': + description: Unexpected internal errors. + delete: + summary: Delete the label specified by ID. + description: > + Delete the label specified by ID. + parameters: + - name: id + in: path + type: integer + format: int64 + required: true + description: Label ID + tags: + - Products + responses: + '200': + description: Delete successfully. + '400': + description: Invalid parameters. + '401': + description: User need to log in first. + '404': + description: The resource does not exist. + '500': + description: Unexpected internal errors. /replications: post: summary: Trigger the replication according to the specified policy. @@ -3056,3 +3214,30 @@ definitions: status: type: string description: The status of jobs. The only valid value is stop for now. + Label: + type: object + properties: + id: + type: integer + description: The ID of label. + name: + type: string + description: The name of label. + description: + type: string + description: The description of label. + color: + type: string + description: The color of label. + scope: + type: integer + description: The scope of label, g for global labels and p for project labels. + project_id: + type: integer + description: The project ID if the label is a project label. + creation_time: + type: string + description: The creation time of label. + update_time: + type: string + description: The update time of label. diff --git a/make/photon/db/registry.sql b/make/photon/db/registry.sql index 0ddc0087d..02c1fb6ca 100644 --- a/make/photon/db/registry.sql +++ b/make/photon/db/registry.sql @@ -254,6 +254,24 @@ create table properties ( UNIQUE (k) ); +create table harbor_label ( + id int NOT NULL AUTO_INCREMENT, + name varchar(128) NOT NULL, + description text, + color varchar(16), +# 's' for system level labels +# 'u' for user level labels + level char(1) NOT NULL, +# 'g' for global labels +# 'p' for project labels + scope char(1) NOT NULL, + project_id int, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP, + PRIMARY KEY(id), + CONSTRAINT unique_name_and_scope UNIQUE (name,scope) + ); + 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 fdf6d9e9a..c5dc67567 100644 --- a/make/photon/db/registry_sqlite.sql +++ b/make/photon/db/registry_sqlite.sql @@ -111,7 +111,7 @@ create table project_metadata ( creation_time timestamp, update_time timestamp, deleted tinyint (1) DEFAULT 0 NOT NULL, - UNIQUE(project_id, name) ON CONFLICT REPLACE, + UNIQUE(project_id, name), FOREIGN KEY (project_id) REFERENCES project(project_id) ); @@ -240,6 +240,27 @@ create table properties ( UNIQUE(k) ); +create table harbor_label ( + id INTEGER PRIMARY KEY, + name varchar(128) NOT NULL, + description text, + color varchar(16), +/* + 's' for system level labels + 'u' for user level labels +*/ + level char(1) NOT NULL, +/* + 'g' for global labels + 'p' for project labels +*/ + scope char(1) NOT NULL, + project_id int, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP, + UNIQUE(name, scope) + ); + create table alembic_version ( version_num varchar(32) NOT NULL ); diff --git a/src/common/const.go b/src/common/const.go index a3c164307..44444bd06 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -29,6 +29,15 @@ const ( RoleDeveloper = 2 RoleGuest = 3 + LabelLevelSystem = "s" + LabelLevelUser = "u" + LabelScopeGlobal = "g" + LabelScopeProject = "p" + + ResourceTypeProject = "p" + ResourceTypeRepository = "r" + ResourceTypeImage = "i" + ExtEndpoint = "ext_endpoint" AUTHMode = "auth_mode" DatabaseType = "database_type" diff --git a/src/common/dao/label.go b/src/common/dao/label.go new file mode 100644 index 000000000..77b26a543 --- /dev/null +++ b/src/common/dao/label.go @@ -0,0 +1,99 @@ +// 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" +) + +// AddLabel creates a label +func AddLabel(label *models.Label) (int64, error) { + now := time.Now() + label.CreationTime = now + label.UpdateTime = now + return GetOrmer().Insert(label) +} + +// GetLabel specified by ID +func GetLabel(id int64) (*models.Label, error) { + label := &models.Label{ + ID: id, + } + if err := GetOrmer().Read(label); err != nil { + if err == orm.ErrNoRows { + return nil, nil + } + return nil, err + } + + return label, nil +} + +// GetTotalOfLabels returns the total count of labels +func GetTotalOfLabels(query *models.LabelQuery) (int64, error) { + qs := getLabelQuerySetter(query) + return qs.Count() +} + +// ListLabels list labels according to the query conditions +func ListLabels(query *models.LabelQuery) ([]*models.Label, error) { + qs := getLabelQuerySetter(query) + if query.Size > 0 { + qs = qs.Limit(query.Size) + if query.Page > 0 { + qs = qs.Offset((query.Page - 1) * query.Size) + } + } + qs = qs.OrderBy("Name") + + labels := []*models.Label{} + _, err := qs.All(&labels) + return labels, err +} + +func getLabelQuerySetter(query *models.LabelQuery) orm.QuerySeter { + qs := GetOrmer().QueryTable(&models.Label{}) + if len(query.Name) > 0 { + qs = qs.Filter("Name", query.Name) + } + if len(query.Level) > 0 { + qs = qs.Filter("Level", query.Level) + } + if len(query.Scope) > 0 { + qs = qs.Filter("Scope", query.Scope) + } + if query.ProjectID != 0 { + qs = qs.Filter("ProjectID", query.ProjectID) + } + return qs +} + +// UpdateLabel ... +func UpdateLabel(label *models.Label) error { + label.UpdateTime = time.Now() + _, err := GetOrmer().Update(label) + return err +} + +// DeleteLabel ... +func DeleteLabel(id int64) error { + _, err := GetOrmer().Delete(&models.Label{ + ID: id, + }) + return err +} diff --git a/src/common/dao/label_test.go b/src/common/dao/label_test.go new file mode 100644 index 000000000..60b389e1b --- /dev/null +++ b/src/common/dao/label_test.go @@ -0,0 +1,91 @@ +// 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/vmware/harbor/src/common" + "github.com/vmware/harbor/src/common/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMethodsOfLabel(t *testing.T) { + label := &models.Label{ + Name: "test", + Level: common.LabelLevelUser, + Scope: common.LabelScopeProject, + ProjectID: 1, + } + + // add + id, err := AddLabel(label) + require.Nil(t, err) + label.ID = id + + // get + l, err := GetLabel(id) + require.Nil(t, err) + assert.Equal(t, label.ID, l.ID) + assert.Equal(t, label.Name, l.Name) + assert.Equal(t, label.Scope, l.Scope) + assert.Equal(t, label.ProjectID, l.ProjectID) + + // get total count + total, err := GetTotalOfLabels(&models.LabelQuery{ + Scope: common.LabelScopeProject, + ProjectID: 1, + }) + require.Nil(t, err) + assert.Equal(t, int64(1), total) + + // list + labels, err := ListLabels(&models.LabelQuery{ + Scope: common.LabelScopeProject, + ProjectID: 1, + Name: label.Name, + }) + require.Nil(t, err) + assert.Equal(t, 1, len(labels)) + + // list + labels, err = ListLabels(&models.LabelQuery{ + Scope: common.LabelScopeProject, + ProjectID: 1, + Name: "not_exist_label", + }) + require.Nil(t, err) + assert.Equal(t, 0, len(labels)) + + // update + newName := "dev" + label.Name = newName + err = UpdateLabel(label) + require.Nil(t, err) + + l, err = GetLabel(id) + require.Nil(t, err) + assert.Equal(t, newName, l.Name) + + // delete + err = DeleteLabel(id) + require.Nil(t, err) + + l, err = GetLabel(id) + require.Nil(t, err) + assert.Nil(t, l) +} diff --git a/src/common/models/base.go b/src/common/models/base.go index 79ce7483e..89f201432 100644 --- a/src/common/models/base.go +++ b/src/common/models/base.go @@ -32,5 +32,6 @@ func init() { new(ClairVulnTimestamp), new(WatchItem), new(ProjectMetadata), - new(ConfigEntry)) + new(ConfigEntry), + new(Label)) } diff --git a/src/common/models/label.go b/src/common/models/label.go new file mode 100644 index 000000000..90d3f278f --- /dev/null +++ b/src/common/models/label.go @@ -0,0 +1,94 @@ +// 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 models + +import ( + "fmt" + "time" + + "github.com/astaxie/beego/validation" + "github.com/vmware/harbor/src/common" +) + +// Label holds information used for a label +type Label struct { + ID int64 `orm:"pk;auto;column(id)" json:"id"` + Name string `orm:"column(name)" json:"name"` + Description string `orm:"column(description)" json:"description"` + Color string `orm:"column(color)" json:"color"` + Level string `orm:"column(level)" json:"-"` + Scope string `orm:"column(scope)" json:"scope"` + ProjectID int64 `orm:"column(project_id)" json:"project_id"` + CreationTime time.Time `orm:"column(creation_time)" json:"creation_time"` + UpdateTime time.Time `orm:"column(update_time)" json:"update_time"` +} + +//TableName ... +func (l *Label) TableName() string { + return "harbor_label" +} + +// LabelQuery : query parameters for labels +type LabelQuery struct { + Name string + Level string + Scope string + ProjectID int64 + Pagination +} + +// Valid ... +func (l *Label) Valid(v *validation.Validation) { + if len(l.Name) == 0 { + v.SetError("name", "cannot be empty") + } + if len(l.Name) > 128 { + v.SetError("name", "max length is 128") + } + + if l.Scope != common.LabelScopeGlobal && l.Scope != common.LabelScopeProject { + v.SetError("scope", fmt.Sprintf("invalid: %s", l.Scope)) + } else if l.Scope == common.LabelScopeProject && l.ProjectID <= 0 { + v.SetError("project_id", fmt.Sprintf("invalid: %d", l.ProjectID)) + } +} + +/* +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"` +} + + +// 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)) + } +} +*/ diff --git a/src/common/models/label_test.go b/src/common/models/label_test.go new file mode 100644 index 000000000..575c8b792 --- /dev/null +++ b/src/common/models/label_test.go @@ -0,0 +1,86 @@ +// 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 models + +import ( + "testing" + + "github.com/astaxie/beego/validation" + "github.com/stretchr/testify/assert" +) + +func TestValidOfLabel(t *testing.T) { + cases := []struct { + label *Label + hasError bool + }{ + { + label: &Label{ + Name: "", + }, + hasError: true, + }, + { + label: &Label{ + Name: "test", + Scope: "", + }, + hasError: true, + }, + { + label: &Label{ + Name: "test", + Scope: "invalid_scope", + }, + hasError: true, + }, + { + label: &Label{ + Name: "test", + Scope: "g", + }, + hasError: false, + }, + { + label: &Label{ + Name: "test", + Scope: "p", + }, + hasError: true, + }, + { + label: &Label{ + Name: "test", + Scope: "p", + ProjectID: -1, + }, + hasError: true, + }, + { + label: &Label{ + Name: "test", + Scope: "p", + ProjectID: 1, + }, + hasError: false, + }, + } + + for _, c := range cases { + v := &validation.Validation{} + c.label.Valid(v) + assert.Equal(t, c.hasError, v.HasErrors()) + } +} diff --git a/src/ui/api/harborapi_test.go b/src/ui/api/harborapi_test.go index abca6f0e1..b63ce193f 100644 --- a/src/ui/api/harborapi_test.go +++ b/src/ui/api/harborapi_test.go @@ -132,6 +132,8 @@ func init() { beego.Router("/api/configurations/reset", &ConfigAPI{}, "post:Reset") beego.Router("/api/email/ping", &EmailAPI{}, "post:Ping") beego.Router("/api/replications", &ReplicationAPI{}) + beego.Router("/api/labels", &LabelAPI{}, "post:Post;get:List") + beego.Router("/api/labels/:id([0-9]+", &LabelAPI{}, "get:Get;put:Put;delete:Delete") _ = updateInitPassword(1, "Harbor12345") diff --git a/src/ui/api/label.go b/src/ui/api/label.go new file mode 100644 index 000000000..3d7dfe978 --- /dev/null +++ b/src/ui/api/label.go @@ -0,0 +1,263 @@ +// 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" +) + +// LabelAPI handles requests for label management +type LabelAPI struct { + label *models.Label + BaseController +} + +// Prepare ... +func (l *LabelAPI) Prepare() { + l.BaseController.Prepare() + method := l.Ctx.Request.Method + if method == http.MethodGet { + return + } + + // POST, PUT, DELETE need login first + if !l.SecurityCtx.IsAuthenticated() { + l.HandleUnauthorized() + return + } + + if method == http.MethodPut || method == http.MethodDelete { + id, err := l.GetInt64FromPath(":id") + if err != nil || id <= 0 { + l.HandleBadRequest("invalid label ID") + return + } + + label, err := dao.GetLabel(id) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to get label %d: %v", id, err)) + return + } + + if label == nil { + l.HandleNotFound(fmt.Sprintf("label %d not found", id)) + return + } + + if label.Scope == common.LabelScopeGlobal && !l.SecurityCtx.IsSysAdmin() || + label.Scope == common.LabelScopeProject && !l.SecurityCtx.HasAllPerm(label.ProjectID) { + l.HandleForbidden(l.SecurityCtx.GetUsername()) + return + } + l.label = label + } +} + +// Post creates a label +func (l *LabelAPI) Post() { + label := &models.Label{} + l.DecodeJSONReqAndValidate(label) + label.Level = common.LabelLevelUser + + switch label.Scope { + case common.LabelScopeGlobal: + if !l.SecurityCtx.IsSysAdmin() { + l.HandleForbidden(l.SecurityCtx.GetUsername()) + return + } + label.ProjectID = 0 + case common.LabelScopeProject: + exist, err := l.ProjectMgr.Exists(label.ProjectID) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to check the existence of project %d: %v", + label.ProjectID, err)) + return + } + if !exist { + l.HandleNotFound(fmt.Sprintf("project %d not found", label.ProjectID)) + return + } + if !l.SecurityCtx.HasAllPerm(label.ProjectID) { + l.HandleForbidden(l.SecurityCtx.GetUsername()) + return + } + } + + labels, err := dao.ListLabels(&models.LabelQuery{ + Name: label.Name, + Level: label.Level, + Scope: label.Scope, + ProjectID: label.ProjectID, + }) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to list labels: %v", err)) + return + } + if len(labels) > 0 { + l.HandleConflict() + return + } + + id, err := dao.AddLabel(label) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to create label: %v", err)) + return + } + + l.Redirect(http.StatusCreated, strconv.FormatInt(id, 10)) +} + +// Get the label specified by ID +func (l *LabelAPI) Get() { + id, err := l.GetInt64FromPath(":id") + if err != nil || id <= 0 { + l.HandleBadRequest(fmt.Sprintf("invalid label ID: %s", l.GetStringFromPath(":id"))) + return + } + + label, err := dao.GetLabel(id) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to get label %d: %v", id, err)) + return + } + + if label == nil { + l.HandleNotFound(fmt.Sprintf("label %d not found", id)) + return + } + + if label.Scope == common.LabelScopeProject { + if !l.SecurityCtx.HasReadPerm(label.ProjectID) { + if !l.SecurityCtx.IsAuthenticated() { + l.HandleUnauthorized() + return + } + l.HandleForbidden(l.SecurityCtx.GetUsername()) + return + } + } + + l.Data["json"] = label + l.ServeJSON() +} + +// List labels according to the query strings +func (l *LabelAPI) List() { + query := &models.LabelQuery{ + Name: l.GetString("name"), + Level: common.LabelLevelUser, + } + + scope := l.GetString("scope") + if scope != common.LabelScopeGlobal && scope != common.LabelScopeProject { + l.HandleBadRequest(fmt.Sprintf("invalid scope: %s", scope)) + return + } + query.Scope = scope + + if scope == common.LabelScopeProject { + projectIDStr := l.GetString("project_id") + if len(projectIDStr) == 0 { + l.HandleBadRequest("project_id is required") + return + } + projectID, err := strconv.ParseInt(projectIDStr, 10, 64) + if err != nil || projectID <= 0 { + l.HandleBadRequest(fmt.Sprintf("invalid project_id: %s", projectIDStr)) + return + } + + if !l.SecurityCtx.HasReadPerm(projectID) { + if !l.SecurityCtx.IsAuthenticated() { + l.HandleUnauthorized() + return + } + l.HandleForbidden(l.SecurityCtx.GetUsername()) + return + } + query.ProjectID = projectID + } + + total, err := dao.GetTotalOfLabels(query) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to get total count of labels: %v", err)) + return + } + + query.Page, query.Size = l.GetPaginationParams() + + labels, err := dao.ListLabels(query) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to list labels: %v", err)) + return + } + + l.SetPaginationHeader(total, query.Page, query.Size) + l.Data["json"] = labels + l.ServeJSON() +} + +// Put updates the label +func (l *LabelAPI) Put() { + label := &models.Label{} + l.DecodeJSONReq(label) + + oldName := l.label.Name + + // only name, description and color can be changed + l.label.Name = label.Name + l.label.Description = label.Description + l.label.Color = label.Color + + l.Validate(l.label) + + if l.label.Name != oldName { + labels, err := dao.ListLabels(&models.LabelQuery{ + Name: l.label.Name, + Level: l.label.Level, + Scope: l.label.Scope, + ProjectID: l.label.ProjectID, + }) + if err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to list labels: %v", err)) + return + } + if len(labels) > 0 { + l.HandleConflict() + return + } + } + + if err := dao.UpdateLabel(l.label); err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to update label %d: %v", l.label.ID, err)) + return + } + +} + +// Delete the label +func (l *LabelAPI) Delete() { + id := l.label.ID + if err := dao.DeleteLabel(id); err != nil { + l.HandleInternalServerError(fmt.Sprintf("failed to delete label %d: %v", id, err)) + return + } +} diff --git a/src/ui/api/label_test.go b/src/ui/api/label_test.go new file mode 100644 index 000000000..71f6022b0 --- /dev/null +++ b/src/ui/api/label_test.go @@ -0,0 +1,435 @@ +// 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" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vmware/harbor/src/common" + "github.com/vmware/harbor/src/common/models" +) + +var ( + labelAPIBasePath = "/api/labels" + labelID int64 +) + +func TestLabelAPIPost(t *testing.T) { + postFunc := func(resp *httptest.ResponseRecorder) error { + id, err := parseResourceID(resp) + if err != nil { + return err + } + labelID = id + return nil + } + + cases := []*codeCheckingCase{ + // 401 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + }, + code: http.StatusUnauthorized, + }, + + // 400 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{}, + credential: nonSysAdmin, + }, + code: http.StatusBadRequest, + }, + + // 403 non-sysadmin try to create global label + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeGlobal, + }, + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 non-member user try to create project label + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 developer try to create project label + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 404 non-exist project + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeProject, + ProjectID: 10000, + }, + credential: projAdmin, + }, + code: http.StatusNotFound, + }, + + // 200 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: projAdmin, + }, + code: http.StatusCreated, + postFunc: postFunc, + }, + + // 409 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPost, + url: labelAPIBasePath, + bodyJSON: &models.Label{ + Name: "test", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: projAdmin, + }, + code: http.StatusConflict, + }, + } + + runCodeCheckingCases(t, cases...) +} + +func TestLabelAPIGet(t *testing.T) { + cases := []*codeCheckingCase{ + // 400 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 0), + }, + code: http.StatusBadRequest, + }, + + // 404 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 1000), + }, + code: http.StatusNotFound, + }, + + // 200 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + }, + code: http.StatusOK, + }, + } + runCodeCheckingCases(t, cases...) +} + +func TestLabelAPIList(t *testing.T) { + cases := []*codeCheckingCase{ + // 400 no scope query string + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: labelAPIBasePath, + }, + code: http.StatusBadRequest, + }, + + // 400 invalid scope + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: labelAPIBasePath, + queryStruct: struct { + Scope string `url:"scope"` + }{ + Scope: "invalid_scope", + }, + }, + code: http.StatusBadRequest, + }, + + // 400 invalid project_id + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodGet, + url: labelAPIBasePath, + queryStruct: struct { + Scope string `url:"scope"` + ProjectID int64 `url:"project_id"` + }{ + Scope: "p", + ProjectID: 0, + }, + }, + code: http.StatusBadRequest, + }, + } + runCodeCheckingCases(t, cases...) + + // 200 + labels := []*models.Label{} + err := handleAndParse(&testingRequest{ + method: http.MethodGet, + url: labelAPIBasePath, + queryStruct: struct { + Scope string `url:"scope"` + ProjectID int64 `url:"project_id"` + Name string `url:"name"` + }{ + Scope: "p", + ProjectID: 1, + Name: "test", + }, + }, &labels) + require.Nil(t, err) + assert.Equal(t, 1, len(labels)) + + err = handleAndParse(&testingRequest{ + method: http.MethodGet, + url: labelAPIBasePath, + queryStruct: struct { + Scope string `url:"scope"` + ProjectID int64 `url:"project_id"` + Name string `url:"name"` + }{ + Scope: "p", + ProjectID: 1, + Name: "dev", + }, + }, &labels) + require.Nil(t, err) + assert.Equal(t, 0, len(labels)) +} + +func TestLabelAPIPut(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + }, + code: http.StatusUnauthorized, + }, + + // 400 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 0), + credential: nonSysAdmin, + }, + code: http.StatusBadRequest, + }, + + // 404 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 10000), + credential: nonSysAdmin, + }, + code: http.StatusNotFound, + }, + + // 403 non-member user + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 developer + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 400 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + bodyJSON: &models.Label{ + Name: "", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: projAdmin, + }, + code: http.StatusBadRequest, + }, + + // 200 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + bodyJSON: &models.Label{ + Name: "product", + Scope: common.LabelScopeProject, + ProjectID: 1, + }, + credential: projAdmin, + }, + code: http.StatusOK, + }, + } + + runCodeCheckingCases(t, cases...) + + label := &models.Label{} + err := handleAndParse(&testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + }, label) + require.Nil(t, err) + assert.Equal(t, "product", label.Name) +} + +func TestLabelAPIDelete(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + }, + code: http.StatusUnauthorized, + }, + + // 400 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 0), + credential: nonSysAdmin, + }, + code: http.StatusBadRequest, + }, + + // 404 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, 10000), + credential: nonSysAdmin, + }, + code: http.StatusNotFound, + }, + + // 403 non-member user + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 developer + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 200 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: projAdmin, + }, + code: http.StatusOK, + }, + + // 404 + &codeCheckingCase{ + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", labelAPIBasePath, labelID), + credential: projAdmin, + }, + code: http.StatusNotFound, + }, + } + + runCodeCheckingCases(t, cases...) +} diff --git a/src/ui/router.go b/src/ui/router.go index eaa511d29..bcdc536ec 100644 --- a/src/ui/router.go +++ b/src/ui/router.go @@ -94,6 +94,8 @@ func initRouters() { beego.Router("/api/configurations/reset", &api.ConfigAPI{}, "post:Reset") 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/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 7098979c7..9dff157c3 100644 --- a/tools/migration/changelog.md +++ b/tools/migration/changelog.md @@ -65,3 +65,7 @@ Changelog for harbor database schema - add pk `id` to table `properties` - remove pk index from column 'k' of table `properties` - alter `name` length from 41 to 256 of table `project` + +## 1.5.0 + + - create table `harbor_label` \ No newline at end of file