From 93731eeb2eddc27a61a67c6fa144796f3878fe90 Mon Sep 17 00:00:00 2001 From: Wenkai Yin Date: Mon, 27 Jan 2020 20:32:14 +0800 Subject: [PATCH] Support add/remove label to/from artifact This commit add supporting for adding/removing label to/from artifacts and populates labels when listing artifacts Signed-off-by: Wenkai Yin --- api/v2.0/swagger.yaml | 108 ++++++++++- .../postgresql/0030_1.11.0_schema.up.sql | 17 +- src/api/artifact/controller.go | 104 +++++++---- src/api/artifact/controller_test.go | 27 ++- src/api/artifact/model.go | 19 +- src/common/rbac/const.go | 7 +- src/common/rbac/project/visitor_role.go | 9 + src/pkg/artifact/dao/dao.go | 14 +- src/pkg/label/dao.go | 158 ++++++++++++++++ src/pkg/label/dao_test.go | 175 ++++++++++++++++++ src/pkg/label/manager.go | 94 ++++++++++ src/pkg/label/manager_test.go | 123 ++++++++++++ src/pkg/label/model.go | 31 ++++ src/pkg/tag/dao/dao.go | 14 +- src/server/v2.0/handler/artifact.go | 28 +++ src/testing/api/artifact/controller.go | 12 ++ src/testing/pkg/label/manager.go | 64 +++++++ 17 files changed, 948 insertions(+), 56 deletions(-) create mode 100644 src/pkg/label/dao.go create mode 100644 src/pkg/label/dao_test.go create mode 100644 src/pkg/label/manager.go create mode 100644 src/pkg/label/manager_test.go create mode 100644 src/pkg/label/model.go create mode 100644 src/testing/pkg/label/manager.go diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index cd9dd4147..5e1f47991 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -270,15 +270,13 @@ paths: - $ref: '#/parameters/reference' - name: addition in: path - description: The addition name, "build_history" for images; "values.yaml", "readme", "dependencies" for charts + description: The type of addition. type: string enum: [build_history, values.yaml, readme, dependencies] required: true responses: '200': - description: Success - schema: - type: string + $ref: '#/responses/200' '400': $ref: '#/responses/400' '401': @@ -289,6 +287,70 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + /projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels: + post: + summary: Add label to artifact + description: Add label to the specified artiact. + tags: + - artifact + operationId: addLabel + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/projectName' + - $ref: '#/parameters/repositoryName' + - $ref: '#/parameters/reference' + - name: label + in: body + description: The label that added to the artifact. Only the ID property is needed. + required: true + schema: + $ref: '#/definitions/Label' + responses: + '200': + $ref: '#/responses/200' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '409': + $ref: '#/responses/409' + '500': + $ref: '#/responses/500' + /projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id}: + delete: + summary: Remove label from artifact + description: Remove the label from the specified artiact. + tags: + - artifact + operationId: removeLabel + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/projectName' + - $ref: '#/parameters/repositoryName' + - $ref: '#/parameters/reference' + - name: label_id + in: path + description: The ID of the label that removed from the artifact. + type: integer + format: int64 + required: true + responses: + '200': + $ref: '#/responses/200' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '409': + $ref: '#/responses/409' + '500': + $ref: '#/responses/500' parameters: requestId: name: X-Request-Id @@ -467,6 +529,10 @@ definitions: $ref: '#/definitions/Tag' addition_links: $ref: '#/definitions/AdditionLinks' + labels: + type: array + items: + $ref: '#/definitions/Label' Tag: type: object properties: @@ -555,4 +621,38 @@ definitions: variant: type: string description: The variant of the CPU + Label: + type: object + properties: + id: + type: integer + format: int64 + description: The ID of the label + name: + type: string + description: The name the label + description: + type: string + description: The description the label + color: + type: string + description: The color the label + scope: + type: string + description: The scope the label + project_id: + type: integer + format: int64 + description: The ID of project that the label belongs to + creation_time: + type: string + format: date-time + description: The creation time the label + update_time: + type: string + format: date-time + description: The update time of the label + deleted: + type: boolean + description: Whether the label is deleted or not diff --git a/make/migrations/postgresql/0030_1.11.0_schema.up.sql b/make/migrations/postgresql/0030_1.11.0_schema.up.sql index 181e9b77e..fadafa44c 100644 --- a/make/migrations/postgresql/0030_1.11.0_schema.up.sql +++ b/make/migrations/postgresql/0030_1.11.0_schema.up.sql @@ -41,4 +41,19 @@ CREATE TABLE artifact_reference FOREIGN KEY (parent_id) REFERENCES artifact_2(id), FOREIGN KEY (child_id) REFERENCES artifact_2(id), CONSTRAINT unique_reference UNIQUE (parent_id, child_id) -); \ No newline at end of file +); + + +/* TODO upgrade: how about keep the table "harbor_resource_label" only for helm v2 chart and use the new table for artifact label reference? */ +/* label_reference records the labels added to the artifact */ +CREATE TABLE label_reference ( + id SERIAL PRIMARY KEY NOT NULL, + label_id int NOT NULL, + artifact_id int NOT NULL, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP, + FOREIGN KEY (label_id) REFERENCES harbor_label(id), + /* TODO replace artifact_2 after finishing the upgrade work */ + FOREIGN KEY (artifact_id) REFERENCES artifact_2(id), + CONSTRAINT unique_label_reference UNIQUE (label_id,artifact_id) + ); diff --git a/src/api/artifact/controller.go b/src/api/artifact/controller.go index ad9621df0..7310adf58 100644 --- a/src/api/artifact/controller.go +++ b/src/api/artifact/controller.go @@ -26,6 +26,7 @@ import ( "github.com/goharbor/harbor/src/pkg/art" "github.com/goharbor/harbor/src/pkg/immutabletag/match" "github.com/goharbor/harbor/src/pkg/immutabletag/match/rule" + "github.com/goharbor/harbor/src/pkg/label" "github.com/opencontainers/go-digest" "strings" @@ -77,6 +78,10 @@ type Controller interface { // The addition is different according to the artifact type: // build history for image; values.yaml, readme and dependencies for chart, etc GetAddition(ctx context.Context, artifactID int64, additionType string) (addition *resolver.Addition, err error) + // AddLabel to the specified artifact + AddLabel(ctx context.Context, artifactID int64, labelID int64) (err error) + // RemoveLabel from the specified artifact + RemoveLabel(ctx context.Context, artifactID int64, labelID int64) (err error) // TODO move this to GC controller? // Prune removes the useless artifact records. The underlying registry data will // be removed during garbage collection @@ -89,6 +94,7 @@ func NewController() Controller { repoMgr: repository.Mgr, artMgr: artifact.Mgr, tagMgr: tag.Mgr, + labelMgr: label.Mgr, abstractor: abstractor.NewAbstractor(), immutableMtr: rule.NewRuleMatcher(), } @@ -100,6 +106,7 @@ type controller struct { repoMgr repository.Manager artMgr artifact.Manager tagMgr tag.Manager + labelMgr label.Manager abstractor abstractor.Abstractor immutableMtr match.ImmutableTagMatcher } @@ -284,6 +291,10 @@ func (c *controller) getByTag(ctx context.Context, repository, tag string, optio } func (c *controller) Delete(ctx context.Context, id int64) error { + // remove labels added to the artifact + if err := c.labelMgr.RemoveAllFrom(ctx, id); err != nil { + return err + } // delete all tags that attached to the artifact _, tags, err := c.tagMgr.List(ctx, &q.Query{ Keywords: map[string]interface{}{ @@ -326,7 +337,6 @@ func (c *controller) ListTags(ctx context.Context, query *q.Query, option *TagOp func (c *controller) DeleteTag(ctx context.Context, tagID int64) error { // Immutable checking is covered in middleware // TODO check signature - // TODO delete label // TODO fire delete tag event return c.tagMgr.Delete(ctx, tagID) } @@ -362,6 +372,14 @@ func (c *controller) GetAddition(ctx context.Context, artifactID int64, addition } } +func (c *controller) AddLabel(ctx context.Context, artifactID int64, labelID int64) error { + return c.labelMgr.AddTo(ctx, labelID, artifactID) +} + +func (c *controller) RemoveLabel(ctx context.Context, artifactID int64, labelID int64) error { + return c.labelMgr.RemoveFrom(ctx, labelID, artifactID) +} + // assemble several part into a single artifact func (c *controller) assembleArtifact(ctx context.Context, art *artifact.Artifact, option *Option) *Artifact { artifact := &Artifact{ @@ -370,34 +388,38 @@ func (c *controller) assembleArtifact(ctx context.Context, art *artifact.Artifac if option == nil { return artifact } - // populate tags if option.WithTag { - _, tgs, err := c.tagMgr.List(ctx, &q.Query{ - Keywords: map[string]interface{}{ - "artifact_id": artifact.ID, - }, - }) - if err == nil { - // assemble tags - for _, tg := range tgs { - artifact.Tags = append(artifact.Tags, c.assembleTag(ctx, tg, option.TagOption)) - } - } else { - log.Errorf("failed to list tag of artifact %d: %v", artifact.ID, err) - } + c.populateTags(ctx, artifact, option.TagOption) } if option.WithLabel { - // TODO populate label + c.populateLabels(ctx, artifact) } if option.WithScanOverview { - // TODO populate scan overview + c.populateScanOverview(ctx, artifact) + } + if option.WithSignature { + c.populateSignature(ctx, artifact) } // populate addition links c.populateAdditionLinks(ctx, artifact) - // TODO populate signature on artifact or label level? return artifact } +func (c *controller) populateTags(ctx context.Context, art *Artifact, option *TagOption) { + _, tags, err := c.tagMgr.List(ctx, &q.Query{ + Keywords: map[string]interface{}{ + "artifact_id": art.ID, + }, + }) + if err != nil { + log.Errorf("failed to list tag of artifact %d: %v", art.ID, err) + return + } + for _, tag := range tags { + art.Tags = append(art.Tags, c.assembleTag(ctx, tag, option)) + } +} + // assemble several part into a single tag func (c *controller) assembleTag(ctx context.Context, tag *tm.Tag, option *TagOption) *Tag { t := &Tag{ @@ -407,30 +429,46 @@ func (c *controller) assembleTag(ctx context.Context, tag *tm.Tag, option *TagOp return t } if option.WithImmutableStatus { - repo, err := c.repoMgr.Get(ctx, tag.RepositoryID) - if err != nil { - log.Error(err) - } else { - t.Immutable = c.isImmutable(repo.ProjectID, repo.Name, tag.Name) - } + c.populateImmutableStatus(ctx, t) } - // TODO populate signature on tag level? return t } -// check whether the tag is Immutable -func (c *controller) isImmutable(projectID int64, repo string, tag string) bool { - _, repoName := utils.ParseRepository(repo) - matched, err := c.immutableMtr.Match(projectID, art.Candidate{ +func (c *controller) populateLabels(ctx context.Context, art *Artifact) { + labels, err := c.labelMgr.ListByArtifact(ctx, art.ID) + if err != nil { + log.Errorf("failed to list labels of artifact %d: %v", art.ID, err) + return + } + art.Labels = labels +} + +func (c *controller) populateImmutableStatus(ctx context.Context, tag *Tag) { + repo, err := c.repoMgr.Get(ctx, tag.RepositoryID) + if err != nil { + log.Error(err) + return + } + _, repoName := utils.ParseRepository(repo.Name) + matched, err := c.immutableMtr.Match(repo.ProjectID, art.Candidate{ Repository: repoName, - Tag: tag, - NamespaceID: projectID, + Tag: tag.Name, + NamespaceID: repo.ProjectID, }) if err != nil { log.Error(err) - return false + return } - return matched + tag.Immutable = matched +} + +func (c *controller) populateScanOverview(ctx context.Context, art *Artifact) { + // TODO implement +} + +func (c *controller) populateSignature(ctx context.Context, art *Artifact) { + // TODO implement + // TODO populate signature on artifact or tag level? } func (c *controller) populateAdditionLinks(ctx context.Context, artifact *Artifact) { diff --git a/src/api/artifact/controller_test.go b/src/api/artifact/controller_test.go index 1d88fc2cd..27e97dc66 100644 --- a/src/api/artifact/controller_test.go +++ b/src/api/artifact/controller_test.go @@ -26,6 +26,7 @@ import ( "github.com/goharbor/harbor/src/pkg/tag/model/tag" arttesting "github.com/goharbor/harbor/src/testing/pkg/artifact" immutesting "github.com/goharbor/harbor/src/testing/pkg/immutabletag" + "github.com/goharbor/harbor/src/testing/pkg/label" repotesting "github.com/goharbor/harbor/src/testing/pkg/repository" tagtesting "github.com/goharbor/harbor/src/testing/pkg/tag" "github.com/stretchr/testify/mock" @@ -69,6 +70,7 @@ type controllerTestSuite struct { repoMgr *repotesting.FakeManager artMgr *arttesting.FakeManager tagMgr *tagtesting.FakeManager + labelMgr *label.FakeManager abstractor *fakeAbstractor immutableMtr *immutesting.FakeMatcher } @@ -77,12 +79,14 @@ func (c *controllerTestSuite) SetupTest() { c.repoMgr = &repotesting.FakeManager{} c.artMgr = &arttesting.FakeManager{} c.tagMgr = &tagtesting.FakeManager{} + c.labelMgr = &label.FakeManager{} c.abstractor = &fakeAbstractor{} c.immutableMtr = &immutesting.FakeMatcher{} c.ctl = &controller{ repoMgr: c.repoMgr, artMgr: c.artMgr, tagMgr: c.tagMgr, + labelMgr: c.labelMgr, abstractor: c.abstractor, immutableMtr: c.immutableMtr, } @@ -125,7 +129,7 @@ func (c *controllerTestSuite) TestAssembleArtifact() { TagOption: &TagOption{ WithImmutableStatus: false, }, - WithLabel: false, + WithLabel: true, WithScanOverview: true, WithSignature: true, } @@ -142,6 +146,13 @@ func (c *controllerTestSuite) TestAssembleArtifact() { Name: "library/hello-world", }, nil) ctx := internal.SetAPIVersion(nil, "2.0") + lb := &models.Label{ + ID: 1, + Name: "label", + } + c.labelMgr.On("ListByArtifact").Return([]*models.Label{ + lb, + }, nil) artifact := c.ctl.assembleArtifact(ctx, art, option) c.Require().NotNil(artifact) c.Equal(art.ID, artifact.ID) @@ -151,6 +162,7 @@ func (c *controllerTestSuite) TestAssembleArtifact() { c.False(artifact.AdditionLinks["build_history"].Absolute) c.Equal("/api/2.0/projects/library/repositories/hello-world/artifacts/sha256:123/additions/build_history", artifact.AdditionLinks["build_history"].HREF) + c.Contains(artifact.Labels, lb) // TODO check other fields of option } @@ -413,6 +425,7 @@ func (c *controllerTestSuite) TestDelete() { }, }, nil) c.tagMgr.On("Delete").Return(nil) + c.labelMgr.On("RemoveAllFrom").Return(nil) err := c.ctl.Delete(nil, 1) c.Require().Nil(err) c.artMgr.AssertExpectations(c.T()) @@ -485,6 +498,18 @@ func (c *controllerTestSuite) TestGetAddition() { c.Require().Nil(err) } +func (c *controllerTestSuite) TestAddTo() { + c.labelMgr.On("AddTo").Return(nil) + err := c.ctl.AddLabel(nil, 1, 1) + c.Require().Nil(err) +} + +func (c *controllerTestSuite) TestRemoveFrom() { + c.labelMgr.On("RemoveFrom").Return(nil) + err := c.ctl.RemoveLabel(nil, 1, 1) + c.Require().Nil(err) +} + func TestControllerTestSuite(t *testing.T) { suite.Run(t, &controllerTestSuite{}) } diff --git a/src/api/artifact/model.go b/src/api/artifact/model.go index 9b37cdbfb..3a4706f39 100644 --- a/src/api/artifact/model.go +++ b/src/api/artifact/model.go @@ -16,6 +16,7 @@ package artifact import ( "github.com/go-openapi/strfmt" + cmodels "github.com/goharbor/harbor/src/common/models" "github.com/goharbor/harbor/src/pkg/artifact" "github.com/goharbor/harbor/src/pkg/tag/model/tag" "github.com/goharbor/harbor/src/server/v2.0/models" @@ -25,7 +26,8 @@ import ( type Artifact struct { artifact.Artifact Tags []*Tag // the list of tags that attached to the artifact - AdditionLinks map[string]*AdditionLink // the link for build history(image), values.yaml(chart), dependency(chart), etc + AdditionLinks map[string]*AdditionLink // the resource link for build history(image), values.yaml(chart), dependency(chart), etc + Labels []*cmodels.Label // TODO add other attrs: signature, scan result, etc } @@ -82,6 +84,19 @@ func (a *Artifact) ToSwagger() *models.Artifact { Href: link.HREF, } } + for _, label := range a.Labels { + art.Labels = append(art.Labels, &models.Label{ + ID: label.ID, + Name: label.Name, + Description: label.Description, + Color: label.Color, + CreationTime: strfmt.DateTime(label.CreationTime), + ProjectID: label.ProjectID, + Scope: label.Scope, + UpdateTime: strfmt.DateTime(label.UpdateTime), + Deleted: label.Deleted, + }) + } return art } @@ -89,7 +104,7 @@ func (a *Artifact) ToSwagger() *models.Artifact { type Tag struct { tag.Tag Immutable bool - // TODO add other attrs: signature, label, etc + // TODO add other attrs: signature, etc } // AdditionLink is a link via that the addition can be fetched diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go index e7fb65711..f3c7edd67 100755 --- a/src/common/rbac/const.go +++ b/src/common/rbac/const.go @@ -52,9 +52,9 @@ const ( ResourceRepository = Resource("repository") ResourceTagRetention = Resource("tag-retention") ResourceImmutableTag = Resource("immutable-tag") - ResourceRepositoryLabel = Resource("repository-label") - ResourceRepositoryTag = Resource("repository-tag") // TODO remove - ResourceRepositoryTagLabel = Resource("repository-tag-label") + ResourceRepositoryLabel = Resource("repository-label") // TODO remove + ResourceRepositoryTag = Resource("repository-tag") // TODO remove + ResourceRepositoryTagLabel = Resource("repository-tag-label") // TODO remove ResourceRepositoryTagManifest = Resource("repository-tag-manifest") ResourceRepositoryTagScanJob = Resource("repository-tag-scan-job") // TODO: remove ResourceRepositoryTagVulnerability = Resource("repository-tag-vulnerability") // TODO: remove @@ -65,5 +65,6 @@ const ( ResourceArtifact = Resource("artifact") ResourceTag = Resource("tag") ResourceArtifactAddition = Resource("artifact-addition") + ResourceArtifactLabel = Resource("artifact-label") ResourceSelf = Resource("") // subresource for self ) diff --git a/src/common/rbac/project/visitor_role.go b/src/common/rbac/project/visitor_role.go index de58ab546..7034a676d 100755 --- a/src/common/rbac/project/visitor_role.go +++ b/src/common/rbac/project/visitor_role.go @@ -135,6 +135,9 @@ var ( {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, }, "master": { @@ -232,6 +235,9 @@ var ( {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, }, "developer": { @@ -294,6 +300,9 @@ var ( {Resource: rbac.ResourceArtifactAddition, Action: rbac.ActionRead}, {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, + + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, }, "guest": { diff --git a/src/pkg/artifact/dao/dao.go b/src/pkg/artifact/dao/dao.go index a8268d56b..1a3c7cfba 100644 --- a/src/pkg/artifact/dao/dao.go +++ b/src/pkg/artifact/dao/dao.go @@ -166,12 +166,14 @@ func (d *dao) CreateReference(ctx context.Context, reference *ArtifactReference) return 0, err } id, err := ormer.Insert(reference) - if e := orm.AsConflictError(err, "reference already exists, parent artifact ID: %d, child artifact ID: %d", - reference.ParentID, reference.ChildID); e != nil { - err = e - } else if e := orm.AsForeignKeyError(err, "the reference tries to reference a non existing artifact, parent artifact ID: %d, child artifact ID: %d", - reference.ParentID, reference.ChildID); e != nil { - err = e + if err != nil { + if e := orm.AsConflictError(err, "reference already exists, parent artifact ID: %d, child artifact ID: %d", + reference.ParentID, reference.ChildID); e != nil { + err = e + } else if e := orm.AsForeignKeyError(err, "the reference tries to reference a non existing artifact, parent artifact ID: %d, child artifact ID: %d", + reference.ParentID, reference.ChildID); e != nil { + err = e + } } return id, err } diff --git a/src/pkg/label/dao.go b/src/pkg/label/dao.go new file mode 100644 index 000000000..59e5fd41b --- /dev/null +++ b/src/pkg/label/dao.go @@ -0,0 +1,158 @@ +// 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 label + +import ( + "context" + beego_orm "github.com/astaxie/beego/orm" + "github.com/goharbor/harbor/src/common/models" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/internal/orm" + "github.com/goharbor/harbor/src/pkg/q" +) + +func init() { + beego_orm.RegisterModel(&Reference{}) +} + +// DAO is the data access object interface for label +type DAO interface { + // Get the specified label + Get(ctx context.Context, id int64) (label *models.Label, err error) + // Create the label + Create(ctx context.Context, label *models.Label) (id int64, err error) + // Delete the label + Delete(ctx context.Context, id int64) (err error) + // List labels that added to the artifact specified by the ID + ListByArtifact(ctx context.Context, artifactID int64) (labels []*models.Label, err error) + // Create label reference + CreateReference(ctx context.Context, reference *Reference) (id int64, err error) + // Delete the label reference specified by ID + DeleteReference(ctx context.Context, id int64) (err error) + // Delete label references specified by query + DeleteReferences(ctx context.Context, query *q.Query) (n int64, err error) +} + +// NewDAO creates an instance of the default DAO +func NewDAO() DAO { + return &defaultDAO{} +} + +type defaultDAO struct{} + +func (d *defaultDAO) Get(ctx context.Context, id int64) (*models.Label, error) { + ormer, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + label := &models.Label{ + ID: id, + } + if err = ormer.Read(label); err != nil { + if e := orm.AsNotFoundError(err, "label %d not found", id); e != nil { + err = e + } + return nil, err + } + return label, nil +} + +func (d *defaultDAO) Create(ctx context.Context, label *models.Label) (int64, error) { + ormer, err := orm.FromContext(ctx) + if err != nil { + return 0, err + } + id, err := ormer.Insert(label) + if err != nil { + if e := orm.AsConflictError(err, "label %s already exists", label.Name); e != nil { + err = e + } + } + return id, err +} + +func (d *defaultDAO) Delete(ctx context.Context, id int64) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Delete(&models.Label{ + ID: id, + }) + if err != nil { + return err + } + if n == 0 { + return ierror.NotFoundError(nil).WithMessage("label %d not found", id) + } + return nil +} + +func (d *defaultDAO) ListByArtifact(ctx context.Context, artifactID int64) ([]*models.Label, error) { + sql := `select label.* from harbor_label label + join label_reference ref on label.id = ref.label_id + where ref.artifact_id = ?` + ormer, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + labels := []*models.Label{} + if _, err = ormer.Raw(sql, artifactID).QueryRows(&labels); err != nil { + return nil, err + } + return labels, nil +} +func (d *defaultDAO) CreateReference(ctx context.Context, ref *Reference) (int64, error) { + ormer, err := orm.FromContext(ctx) + if err != nil { + return 0, err + } + id, err := ormer.Insert(ref) + if err != nil { + if e := orm.AsConflictError(err, "label %d is already added to the artifact %d", + ref.LabelID, ref.ArtifactID); e != nil { + err = e + } else if e := orm.AsForeignKeyError(err, "the reference tries to refer a non existing label %d or artifact %d", + ref.LabelID, ref.ArtifactID); e != nil { + err = ierror.New(e).WithCode(ierror.NotFoundCode).WithMessage(e.Message) + } + } + return id, err +} + +func (d *defaultDAO) DeleteReference(ctx context.Context, id int64) error { + ormer, err := orm.FromContext(ctx) + if err != nil { + return err + } + n, err := ormer.Delete(&Reference{ + ID: id, + }) + if err != nil { + return err + } + if n == 0 { + return ierror.NotFoundError(nil).WithMessage("label reference %d not found", id) + } + return nil +} + +func (d *defaultDAO) DeleteReferences(ctx context.Context, query *q.Query) (int64, error) { + qs, err := orm.QuerySetter(ctx, &Reference{}, query) + if err != nil { + return 0, err + } + return qs.Delete() +} diff --git a/src/pkg/label/dao_test.go b/src/pkg/label/dao_test.go new file mode 100644 index 000000000..143335034 --- /dev/null +++ b/src/pkg/label/dao_test.go @@ -0,0 +1,175 @@ +// 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 label + +import ( + "context" + beegoorm "github.com/astaxie/beego/orm" + common_dao "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/internal/orm" + artdao "github.com/goharbor/harbor/src/pkg/artifact/dao" + "github.com/goharbor/harbor/src/pkg/q" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/suite" + "testing" +) + +type labelDaoTestSuite struct { + suite.Suite + dao DAO + artDAO artdao.DAO + ctx context.Context + artID int64 + id int64 + refID int64 +} + +func (l *labelDaoTestSuite) SetupSuite() { + common_dao.PrepareTestForPostgresSQL() + l.dao = &defaultDAO{} + l.artDAO = artdao.New() + l.ctx = orm.NewContext(nil, beegoorm.NewOrm()) +} + +func (l *labelDaoTestSuite) SetupTest() { + id, err := l.dao.Create(l.ctx, &models.Label{ + Name: "label_for_label_dao_test_suite", + Scope: "g", + }) + l.Require().Nil(err) + l.id = id + + id, err = l.artDAO.Create(l.ctx, &artdao.Artifact{ + Type: "IMAGE", + MediaType: v1.MediaTypeImageConfig, + ManifestMediaType: v1.MediaTypeImageManifest, + ProjectID: 1, + RepositoryID: 1, + Digest: "sha256", + }) + l.Require().Nil(err) + l.artID = id + + id, err = l.dao.CreateReference(l.ctx, &Reference{ + LabelID: l.id, + ArtifactID: l.artID, + }) + l.Require().Nil(err) + l.refID = id +} + +func (l *labelDaoTestSuite) TearDownTest() { + err := l.dao.DeleteReference(l.ctx, l.refID) + l.Require().Nil(err) + + err = l.dao.Delete(l.ctx, l.id) + l.Require().Nil(err) + + err = l.artDAO.Delete(l.ctx, l.artID) + l.Require().Nil(err) +} + +func (l *labelDaoTestSuite) TestGet() { + // not found + _, err := l.dao.Get(l.ctx, 1000) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.NotFoundCode)) + + // success + label, err := l.dao.Get(l.ctx, l.id) + l.Require().Nil(err) + l.Equal(l.id, label.ID) +} + +func (l *labelDaoTestSuite) TestCreate() { + // happy pass is covered by SetupTest + + // conflict + _, err := l.dao.Create(l.ctx, &models.Label{ + Name: "label_for_label_dao_test_suite", + Scope: "g", + }) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.ConflictCode)) +} + +func (l *labelDaoTestSuite) TestDelete() { + // happy pass is covered by TearDownTest + + // not found + err := l.dao.Delete(l.ctx, 1000) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.NotFoundCode)) +} + +func (l *labelDaoTestSuite) TestListByResource() { + labels, err := l.dao.ListByArtifact(l.ctx, l.artID) + l.Require().Nil(err) + l.Require().Len(labels, 1) + l.Equal(l.id, labels[0].ID) +} + +func (l *labelDaoTestSuite) TestCreateReference() { + // happy pass is covered by SetupTest + + // conflict + _, err := l.dao.CreateReference(l.ctx, &Reference{ + LabelID: l.id, + ArtifactID: l.artID, + }) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.ConflictCode)) + + // violating foreign key constraint: the label that the ref tries to refer doesn't exist + _, err = l.dao.CreateReference(l.ctx, &Reference{ + LabelID: 1000, + ArtifactID: l.artID, + }) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.NotFoundCode)) + + // violating foreign key constraint: the artifact that the ref tries to refer doesn't exist + _, err = l.dao.CreateReference(l.ctx, &Reference{ + LabelID: l.id, + ArtifactID: 1000, + }) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.NotFoundCode)) +} + +func (l *labelDaoTestSuite) DeleteReference() { + // happy pass is covered by TearDownTest + + // not found + err := l.dao.DeleteReference(l.ctx, 1000) + l.Require().NotNil(err) + l.True(ierror.IsErr(err, ierror.NotFoundCode)) +} + +func (l *labelDaoTestSuite) DeleteReferences() { + n, err := l.dao.DeleteReferences(l.ctx, &q.Query{ + Keywords: map[string]interface{}{ + "LabelID": 1000, + }, + }) + l.Require().Nil(err) + l.Equal(int64(0), n) +} + +func TestLabelDaoTestSuite(t *testing.T) { + suite.Run(t, &labelDaoTestSuite{}) +} diff --git a/src/pkg/label/manager.go b/src/pkg/label/manager.go new file mode 100644 index 000000000..801f1e687 --- /dev/null +++ b/src/pkg/label/manager.go @@ -0,0 +1,94 @@ +// 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 label + +import ( + "context" + "github.com/goharbor/harbor/src/common/models" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/pkg/q" + "time" +) + +// Mgr is a global instance of label manager +var Mgr = New() + +// Manager manages the labels and references between label and resource +type Manager interface { + // Get the label specified by ID + Get(ctx context.Context, id int64) (label *models.Label, err error) + // List labels that added to the artifact specified by the ID + ListByArtifact(ctx context.Context, artifactID int64) (labels []*models.Label, err error) + // Add label to the artifact specified the ID + AddTo(ctx context.Context, labelID int64, artifactID int64) (err error) + // Remove the label added to the artifact specified by the ID + RemoveFrom(ctx context.Context, labelID int64, artifactID int64) (err error) + // Remove all labels added to the artifact specified by the ID + RemoveAllFrom(ctx context.Context, artifactID int64) (err error) +} + +// New creates an instance of the default label manager +func New() Manager { + return &manager{ + dao: &defaultDAO{}, + } +} + +type manager struct { + dao DAO +} + +func (m *manager) Get(ctx context.Context, id int64) (*models.Label, error) { + return m.dao.Get(ctx, id) +} + +func (m *manager) ListByArtifact(ctx context.Context, artifactID int64) ([]*models.Label, error) { + return m.dao.ListByArtifact(ctx, artifactID) +} + +func (m *manager) AddTo(ctx context.Context, labelID int64, artifactID int64) error { + now := time.Now() + _, err := m.dao.CreateReference(ctx, &Reference{ + LabelID: labelID, + ArtifactID: artifactID, + CreationTime: now, + UpdateTime: now, + }) + return err +} +func (m *manager) RemoveFrom(ctx context.Context, labelID int64, artifactID int64) error { + n, err := m.dao.DeleteReferences(ctx, &q.Query{ + Keywords: map[string]interface{}{ + "LabelID": labelID, + "ArtifactID": artifactID, + }, + }) + if err != nil { + return err + } + if n == 0 { + return ierror.NotFoundError(nil).WithMessage("reference with label %d and artifact %d not found", labelID, artifactID) + } + return nil +} + +func (m *manager) RemoveAllFrom(ctx context.Context, artifactID int64) error { + _, err := m.dao.DeleteReferences(ctx, &q.Query{ + Keywords: map[string]interface{}{ + "ArtifactID": artifactID, + }, + }) + return err +} diff --git a/src/pkg/label/manager_test.go b/src/pkg/label/manager_test.go new file mode 100644 index 000000000..afcb9c7e6 --- /dev/null +++ b/src/pkg/label/manager_test.go @@ -0,0 +1,123 @@ +// 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 label + +import ( + "context" + "github.com/goharbor/harbor/src/common/models" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/pkg/q" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "testing" +) + +type fakeDao struct { + mock.Mock +} + +func (f *fakeDao) Get(ctx context.Context, id int64) (*models.Label, error) { + args := f.Called() + var label *models.Label + if args.Get(0) != nil { + label = args.Get(0).(*models.Label) + } + return label, args.Error(1) +} +func (f *fakeDao) Create(ctx context.Context, label *models.Label) (int64, error) { + args := f.Called() + return int64(args.Int(0)), args.Error(1) +} +func (f *fakeDao) Delete(ctx context.Context, id int64) error { + args := f.Called() + return args.Error(0) +} +func (f *fakeDao) ListByArtifact(ctx context.Context, artifactID int64) ([]*models.Label, error) { + args := f.Called() + var labels []*models.Label + if args.Get(0) != nil { + labels = args.Get(0).([]*models.Label) + } + return labels, args.Error(1) +} +func (f *fakeDao) CreateReference(ctx context.Context, reference *Reference) (int64, error) { + args := f.Called() + return int64(args.Int(0)), args.Error(1) +} +func (f *fakeDao) DeleteReference(ctx context.Context, id int64) error { + args := f.Called() + return args.Error(0) +} +func (f *fakeDao) DeleteReferences(ctx context.Context, query *q.Query) (int64, error) { + args := f.Called() + return int64(args.Int(0)), args.Error(1) +} + +type managerTestSuite struct { + suite.Suite + mgr *manager + dao *fakeDao +} + +func (m *managerTestSuite) SetupTest() { + m.dao = &fakeDao{} + m.mgr = &manager{ + dao: m.dao, + } +} + +func (m *managerTestSuite) TestGet() { + m.dao.On("Get").Return(nil, nil) + _, err := m.mgr.Get(nil, 1) + m.Require().Nil(err) +} + +func (m *managerTestSuite) TestListArtifact() { + m.dao.On("ListByArtifact").Return(nil, nil) + _, err := m.mgr.ListByArtifact(nil, 1) + m.Require().Nil(err) +} + +func (m *managerTestSuite) TestAddTo() { + m.dao.On("CreateReference").Return(1, nil) + err := m.mgr.AddTo(nil, 1, 1) + m.Require().Nil(err) +} + +func (m *managerTestSuite) TestRemoveFrom() { + // success + m.dao.On("DeleteReferences").Return(1, nil) + err := m.mgr.RemoveFrom(nil, 1, 1) + m.Require().Nil(err) + + // reset mock + m.SetupTest() + + // not found + m.dao.On("DeleteReferences").Return(0, nil) + err = m.mgr.RemoveFrom(nil, 1, 1) + m.Require().NotNil(err) + m.True(ierror.IsErr(err, ierror.NotFoundCode)) +} + +func (m *managerTestSuite) TestRemoveAllFrom() { + m.dao.On("DeleteReferences").Return(2, nil) + err := m.mgr.RemoveAllFrom(nil, 1) + m.Require().Nil(err) +} + +func TestManager(t *testing.T) { + suite.Run(t, &managerTestSuite{}) +} diff --git a/src/pkg/label/model.go b/src/pkg/label/model.go new file mode 100644 index 000000000..c0b97bd1b --- /dev/null +++ b/src/pkg/label/model.go @@ -0,0 +1,31 @@ +// 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 label + +import "time" + +// Reference is the reference of label and artifact +type Reference struct { + ID int64 `orm:"pk;auto;column(id)"` + LabelID int64 `orm:"column(label_id)"` + ArtifactID int64 `orm:"column(artifact_id)"` + CreationTime time.Time `orm:"column(creation_time);auto_now_add"` + UpdateTime time.Time `orm:"column(update_time);auto_now"` +} + +// TableName defines the database table name +func (r *Reference) TableName() string { + return "label_reference" +} diff --git a/src/pkg/tag/dao/dao.go b/src/pkg/tag/dao/dao.go index 334185e22..ed45ffdb2 100644 --- a/src/pkg/tag/dao/dao.go +++ b/src/pkg/tag/dao/dao.go @@ -96,12 +96,14 @@ func (d *dao) Create(ctx context.Context, tag *tag.Tag) (int64, error) { return 0, err } id, err := ormer.Insert(tag) - if e := orm.AsConflictError(err, "tag %s already exists under the repository %d", - tag.Name, tag.RepositoryID); e != nil { - err = e - } else if e := orm.AsForeignKeyError(err, "the tag %s tries to attach to a non existing artifact %d", - tag.Name, tag.ArtifactID); e != nil { - err = e + if err != nil { + if e := orm.AsConflictError(err, "tag %s already exists under the repository %d", + tag.Name, tag.RepositoryID); e != nil { + err = e + } else if e := orm.AsForeignKeyError(err, "the tag %s tries to attach to a non existing artifact %d", + tag.Name, tag.ArtifactID); e != nil { + err = e + } } return id, err } diff --git a/src/server/v2.0/handler/artifact.go b/src/server/v2.0/handler/artifact.go index b1fdca788..553357c11 100644 --- a/src/server/v2.0/handler/artifact.go +++ b/src/server/v2.0/handler/artifact.go @@ -193,6 +193,34 @@ func (a *artifactAPI) GetAddition(ctx context.Context, params operation.GetAddit }) } +func (a *artifactAPI) AddLabel(ctx context.Context, params operation.AddLabelParams) middleware.Responder { + if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionCreate, rbac.ResourceArtifactLabel); err != nil { + return a.SendError(ctx, err) + } + art, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, nil) + if err != nil { + return a.SendError(ctx, err) + } + if err = a.artCtl.AddLabel(ctx, art.ID, params.Label.ID); err != nil { + return a.SendError(ctx, err) + } + return operation.NewAddLabelOK() +} + +func (a *artifactAPI) RemoveLabel(ctx context.Context, params operation.RemoveLabelParams) middleware.Responder { + if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionDelete, rbac.ResourceArtifactLabel); err != nil { + return a.SendError(ctx, err) + } + art, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, nil) + if err != nil { + return a.SendError(ctx, err) + } + if err = a.artCtl.RemoveLabel(ctx, art.ID, params.LabelID); err != nil { + return a.SendError(ctx, err) + } + return operation.NewRemoveLabelOK() +} + func option(withTag, withImmutableStatus, withLabel, withScanOverview, withSignature *bool) *artifact.Option { option := &artifact.Option{ WithTag: true, // return the tag by default diff --git a/src/testing/api/artifact/controller.go b/src/testing/api/artifact/controller.go index ef3afe95c..dd257c97f 100644 --- a/src/testing/api/artifact/controller.go +++ b/src/testing/api/artifact/controller.go @@ -107,3 +107,15 @@ func (f *FakeController) GetAddition(ctx context.Context, artifactID int64, addi } return res, args.Error(1) } + +// AddLabel ... +func (f *FakeController) AddLabel(ctx context.Context, artifactID int64, labelID int64) error { + args := f.Called() + return args.Error(0) +} + +// RemoveLabel ... +func (f *FakeController) RemoveLabel(ctx context.Context, artifactID int64, labelID int64) error { + args := f.Called() + return args.Error(0) +} diff --git a/src/testing/pkg/label/manager.go b/src/testing/pkg/label/manager.go new file mode 100644 index 000000000..a39a209fb --- /dev/null +++ b/src/testing/pkg/label/manager.go @@ -0,0 +1,64 @@ +// 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 label + +import ( + "context" + "github.com/goharbor/harbor/src/common/models" + "github.com/stretchr/testify/mock" +) + +// FakeManager is a fake label manager that implement the src/pkg/label.Manager interface +type FakeManager struct { + mock.Mock +} + +// Get ... +func (f *FakeManager) Get(ctx context.Context, id int64) (*models.Label, error) { + args := f.Called() + var label *models.Label + if args.Get(0) != nil { + label = args.Get(0).(*models.Label) + } + return label, args.Error(1) +} + +// ListByArtifact ... +func (f *FakeManager) ListByArtifact(ctx context.Context, artifactID int64) ([]*models.Label, error) { + args := f.Called() + var labels []*models.Label + if args.Get(0) != nil { + labels = args.Get(0).([]*models.Label) + } + return labels, args.Error(1) +} + +// AddTo ... +func (f *FakeManager) AddTo(ctx context.Context, labelID int64, artifactID int64) error { + args := f.Called() + return args.Error(0) +} + +// RemoveFrom ... +func (f *FakeManager) RemoveFrom(ctx context.Context, labelID int64, artifactID int64) error { + args := f.Called() + return args.Error(0) +} + +// RemoveAllFrom ... +func (f *FakeManager) RemoveAllFrom(ctx context.Context, artifactID int64) error { + args := f.Called() + return args.Error(0) +}