diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index b6f3a2604..3b7e30903 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -1000,7 +1000,13 @@ paths: default: false - name: with_immutable_status in: query - description: Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_tag=true" + description: Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + type: boolean + required: false + default: false + - name: with_accessory + in: query + description: Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" type: boolean required: false default: false @@ -1091,6 +1097,12 @@ paths: type: boolean required: false default: false + - name: with_accessory + in: query + description: Specify whether the accessories are included of the returning artifacts. + type: boolean + required: false + default: false # should be in tag level - name: with_signature in: query @@ -1100,7 +1112,7 @@ paths: default: false - name: with_immutable_status in: query - description: Specify whether the immutable status is inclued inside the tags of the returning artifacts. Only works when setting "with_tag=true" + description: Specify whether the immutable status is inclued inside the tags of the returning artifacts. type: boolean required: false default: false @@ -1333,6 +1345,46 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' + /projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories: + get: + summary: List accessories + description: List accessories of the specific artifact + tags: + - artifact + operationId: listAccessories + parameters: + - $ref: '#/parameters/requestId' + - $ref: '#/parameters/projectName' + - $ref: '#/parameters/repositoryName' + - $ref: '#/parameters/reference' + - $ref: '#/parameters/query' + - $ref: '#/parameters/sort' + - $ref: '#/parameters/page' + - $ref: '#/parameters/pageSize' + responses: + '200': + description: Success + headers: + X-Total-Count: + description: The total count of accessories + type: integer + Link: + description: Link refers to the previous page and next page + type: string + schema: + type: array + items: + $ref: '#/definitions/Accessory' + '400': + $ref: '#/responses/400' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' /projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities: get: summary: Get the vulnerabilities addition of the specific artifact @@ -5487,6 +5539,13 @@ parameters: required: true type: integer format: int64 + accessoryId: + name: accessory_id + in: path + description: The ID of the accessory + required: true + type: integer + format: int64 responses: '200': @@ -5827,6 +5886,11 @@ definitions: scan_overview: $ref: '#/definitions/ScanOverview' description: The overview of the scan result. + accessories: + type: array + items: + $ref: '#/definitions/Accessory' + description: The accessory of the artifact. Tag: type: object properties: @@ -8657,3 +8721,42 @@ definitions: format: int64 description: The total storage consumption of blobs, only be seen by the system admin x-omitempty: false + Accessory: + type: object + description: 'The accessory of the artifact' + properties: + id: + type: integer + format: int64 + description: The ID of the accessory + artifact_id: + type: integer + format: int64 + description: The artifact id of the accessory + x-omitempty: false + subject_artifact_id: + type: integer + format: int64 + description: The subject artifact id of the accessory + x-omitempty: false + size: + type: integer + format: int64 + description: The artifact size of the accessory + x-omitempty: false + digest: + type: string + description: The artifact digest of the accessory + x-omitempty: false + type: + type: string + description: The artifact size of the accessory + x-omitempty: false + icon: + type: string + description: The icon of the accessory + x-omitempty: false + creation_time: + type: string + format: date-time + description: The creation time of the accessory diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go index 256c7c67f..088908510 100755 --- a/src/common/rbac/const.go +++ b/src/common/rbac/const.go @@ -55,6 +55,7 @@ const ( ResourceScanner = Resource("scanner") ResourceArtifact = Resource("artifact") ResourceTag = Resource("tag") + ResourceAccessory = Resource("accessory") ResourceArtifactAddition = Resource("artifact-addition") ResourceArtifactLabel = Resource("artifact-label") ResourcePreatPolicy = Resource("preheat-policy") diff --git a/src/common/rbac/project/rbac_role.go b/src/common/rbac/project/rbac_role.go index 8d289f665..b0adff006 100644 --- a/src/common/rbac/project/rbac_role.go +++ b/src/common/rbac/project/rbac_role.go @@ -113,6 +113,8 @@ var ( {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, {Resource: rbac.ResourceTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, @@ -159,6 +161,8 @@ var ( {Resource: rbac.ResourceTagRetention, Action: rbac.ActionList}, {Resource: rbac.ResourceTagRetention, Action: rbac.ActionOperate}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionCreate}, {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionUpdate}, {Resource: rbac.ResourceImmutableTag, Action: rbac.ActionDelete}, @@ -252,6 +256,8 @@ var ( {Resource: rbac.ResourceTag, Action: rbac.ActionList}, {Resource: rbac.ResourceTag, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, + {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, }, @@ -289,6 +295,7 @@ var ( {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, @@ -316,6 +323,7 @@ var ( {Resource: rbac.ResourceScanner, Action: rbac.ActionRead}, {Resource: rbac.ResourceTag, Action: rbac.ActionList}, + {Resource: rbac.ResourceAccessory, Action: rbac.ActionList}, {Resource: rbac.ResourceArtifact, Action: rbac.ActionRead}, {Resource: rbac.ResourceArtifact, Action: rbac.ActionList}, diff --git a/src/controller/artifact/controller.go b/src/controller/artifact/controller.go index 590bb5c4b..b183cf2f5 100644 --- a/src/controller/artifact/controller.go +++ b/src/controller/artifact/controller.go @@ -35,6 +35,7 @@ import ( "github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/accessory" "github.com/goharbor/harbor/src/pkg/artifact" "github.com/goharbor/harbor/src/pkg/artifactrash" "github.com/goharbor/harbor/src/pkg/artifactrash/model" @@ -121,6 +122,7 @@ func NewController() Controller { immutableMtr: rule.NewRuleMatcher(), regCli: registry.Cli, abstractor: NewAbstractor(), + accessoryMgr: accessory.Mgr, } } @@ -135,6 +137,7 @@ type controller struct { immutableMtr match.ImmutableTagMatcher regCli registry.Client abstractor Abstractor + accessoryMgr accessory.Manager } func (c *controller) Ensure(ctx context.Context, repository, digest string, tags ...string) (bool, int64, error) { @@ -229,11 +232,18 @@ func (c *controller) List(ctx context.Context, query *q.Query, option *Option) ( return nil, err } - var artifacts []*Artifact + var res []*Artifact + // Only the displayed accessory will in the artifact list for _, art := range arts { - artifacts = append(artifacts, c.assembleArtifact(ctx, art, option)) + accs, err := c.accessoryMgr.List(ctx, q.New(q.KeyWords{"ArtifactID": art.ID, "digest": art.Digest})) + if err != nil { + return nil, err + } + if len(accs) == 0 || (len(accs) > 0 && accs[0].Display()) { + res = append(res, c.assembleArtifact(ctx, art, option)) + } } - return artifacts, nil + return res, nil } func (c *controller) Get(ctx context.Context, id int64, option *Option) (*Artifact, error) { @@ -283,13 +293,18 @@ func (c *controller) getByTag(ctx context.Context, repository, tag string, optio } func (c *controller) Delete(ctx context.Context, id int64) error { - return c.deleteDeeply(ctx, id, true) + accs, err := c.accessoryMgr.List(ctx, q.New(q.KeyWords{"ArtifactID": id})) + if err != nil { + return err + } + return c.deleteDeeply(ctx, id, true, len(accs) > 0) } // "isRoot" is used to specify whether the artifact is the root parent artifact // the error handling logic for the root parent artifact and others is different -func (c *controller) deleteDeeply(ctx context.Context, id int64, isRoot bool) error { - art, err := c.Get(ctx, id, &Option{WithTag: true}) +// "isAccessory" is used to specify whether the artifact is an accessory. +func (c *controller) deleteDeeply(ctx context.Context, id int64, isRoot, isAccessory bool) error { + art, err := c.Get(ctx, id, &Option{WithTag: true, WithAccessory: true}) if err != nil { // return nil if the nonexistent artifact isn't the root parent if !isRoot && errors.IsErr(err, errors.NotFoundCode) { @@ -298,6 +313,12 @@ func (c *controller) deleteDeeply(ctx context.Context, id int64, isRoot bool) er return err } + if isAccessory { + if err := c.accessoryMgr.DeleteAccessories(ctx, q.New(q.KeyWords{"ArtifactID": art.ID, "Digest": art.Digest})); err != nil && !errors.IsErr(err, errors.NotFoundCode) { + return err + } + } + // the child artifact is referenced by some tags, skip if !isRoot && len(art.Tags) > 0 { return nil @@ -319,6 +340,17 @@ func (c *controller) deleteDeeply(ctx context.Context, id int64, isRoot bool) er // the child artifact is referenced by other artifacts, skip return nil } + + // delete accessories if contains any + for _, acc := range art.Accessories { + // only hard ref accessory should be removed + if acc.IsHard() { + if err = c.deleteDeeply(ctx, acc.GetData().ArtifactID, true, true); err != nil { + return err + } + } + } + // delete child artifacts if contains any for _, reference := range art.References { // delete reference @@ -326,7 +358,7 @@ func (c *controller) deleteDeeply(ctx context.Context, id int64, isRoot bool) er !errors.IsErr(err, errors.NotFoundCode) { return err } - if err = c.deleteDeeply(ctx, reference.ChildID, false); err != nil { + if err = c.deleteDeeply(ctx, reference.ChildID, false, false); err != nil { return err } } @@ -582,6 +614,9 @@ func (c *controller) assembleArtifact(ctx context.Context, art *artifact.Artifac if option.WithLabel { c.populateLabels(ctx, artifact) } + if option.WithAccessory { + c.populateAccessories(ctx, artifact) + } return artifact } @@ -626,3 +661,12 @@ func (c *controller) populateAdditionLinks(ctx context.Context, artifact *Artifa } } } + +func (c *controller) populateAccessories(ctx context.Context, art *Artifact) { + accs, err := c.accessoryMgr.List(ctx, q.New(q.KeyWords{"SubjectArtifactID": art.ID})) + if err != nil { + log.Errorf("failed to list accessories of artifact %d: %v", art.ID, err) + return + } + art.Accessories = accs +} diff --git a/src/controller/artifact/controller_test.go b/src/controller/artifact/controller_test.go index 1e473e0f4..3619e28ac 100644 --- a/src/controller/artifact/controller_test.go +++ b/src/controller/artifact/controller_test.go @@ -28,12 +28,16 @@ import ( "github.com/goharbor/harbor/src/lib/icon" "github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/q" + accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model" + basemodel "github.com/goharbor/harbor/src/pkg/accessory/model/base" "github.com/goharbor/harbor/src/pkg/artifact" "github.com/goharbor/harbor/src/pkg/label/model" repomodel "github.com/goharbor/harbor/src/pkg/repository/model" model_tag "github.com/goharbor/harbor/src/pkg/tag/model/tag" tagtesting "github.com/goharbor/harbor/src/testing/controller/tag" ormtesting "github.com/goharbor/harbor/src/testing/lib/orm" + "github.com/goharbor/harbor/src/testing/pkg/accessory" + accessorytesting "github.com/goharbor/harbor/src/testing/pkg/accessory" arttesting "github.com/goharbor/harbor/src/testing/pkg/artifact" artrashtesting "github.com/goharbor/harbor/src/testing/pkg/artifactrash" "github.com/goharbor/harbor/src/testing/pkg/blob" @@ -69,6 +73,7 @@ type controllerTestSuite struct { abstractor *fakeAbstractor immutableMtr *immutable.FakeMatcher regCli *registry.FakeClient + accMgr *accessory.Manager } func (c *controllerTestSuite) SetupTest() { @@ -80,6 +85,7 @@ func (c *controllerTestSuite) SetupTest() { c.labelMgr = &label.Manager{} c.abstractor = &fakeAbstractor{} c.immutableMtr = &immutable.FakeMatcher{} + c.accMgr = &accessorytesting.Manager{} c.regCli = ®istry.FakeClient{} c.ctl = &controller{ repoMgr: c.repoMgr, @@ -91,6 +97,7 @@ func (c *controllerTestSuite) SetupTest() { abstractor: c.abstractor, immutableMtr: c.immutableMtr, regCli: c.regCli, + accessoryMgr: c.accMgr, } } @@ -105,7 +112,8 @@ func (c *controllerTestSuite) TestAssembleArtifact() { TagOption: &tag.Option{ WithImmutableStatus: false, }, - WithLabel: true, + WithLabel: true, + WithAccessory: true, } tg := &tag.Tag{ Tag: model_tag.Tag{ @@ -126,12 +134,24 @@ func (c *controllerTestSuite) TestAssembleArtifact() { c.labelMgr.On("ListByArtifact", mock.Anything, mock.Anything).Return([]*model.Label{ lb, }, nil) + acc := &basemodel.Default{ + Data: accessorymodel.AccessoryData{ + ID: 1, + ArtifactID: 2, + SubArtifactID: 1, + Type: accessorymodel.TypeCosignSignature, + }, + } + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{ + acc, + }, nil) artifact := c.ctl.assembleArtifact(ctx, art, option) c.Require().NotNil(artifact) c.Equal(art.ID, artifact.ID) c.Equal(icon.DigestOfIconDefault, artifact.Icon) c.Contains(artifact.Tags, tg) c.Contains(artifact.Labels, lb) + c.Contains(artifact.Accessories, acc) // TODO check other fields of option } @@ -249,7 +269,8 @@ func (c *controllerTestSuite) TestCount() { func (c *controllerTestSuite) TestList() { query := &q.Query{} option := &Option{ - WithTag: true, + WithTag: true, + WithAccessory: true, } c.artMgr.On("List", mock.Anything, mock.Anything).Return([]*artifact.Artifact{ { @@ -273,12 +294,14 @@ func (c *controllerTestSuite) TestList() { c.repoMgr.On("List", mock.Anything, mock.Anything).Return([]*repomodel.RepoRecord{ {RepositoryID: 1, Name: "library/hello-world"}, }, nil) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) artifacts, err := c.ctl.List(nil, query, option) c.Require().Nil(err) c.Require().Len(artifacts, 1) c.Equal(int64(1), artifacts[0].ID) c.Require().Len(artifacts[0].Tags, 1) c.Equal(int64(1), artifacts[0].Tags[0].ID) + c.Equal(0, len(artifacts[0].Accessories)) } func (c *controllerTestSuite) TestGet() { @@ -406,7 +429,8 @@ func (c *controllerTestSuite) TestGetByReference() { func (c *controllerTestSuite) TestDeleteDeeply() { // root artifact and doesn't exist c.artMgr.On("Get", mock.Anything, mock.Anything).Return(nil, errors.NotFoundError(nil)) - err := c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, true) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + err := c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, true, false) c.Require().NotNil(err) c.Assert().True(errors.IsErr(err, errors.NotFoundCode)) @@ -415,7 +439,8 @@ func (c *controllerTestSuite) TestDeleteDeeply() { // child artifact and doesn't exist c.artMgr.On("Get", mock.Anything, mock.Anything).Return(nil, errors.NotFoundError(nil)) - err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, false) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, false, false) c.Require().Nil(err) // reset the mock @@ -433,7 +458,8 @@ func (c *controllerTestSuite) TestDeleteDeeply() { }, nil) c.repoMgr.On("Get", mock.Anything, mock.Anything).Return(&repomodel.RepoRecord{}, nil) c.artrashMgr.On("Create").Return(0, nil) - err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, false) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, false, false) c.Require().Nil(err) // reset the mock @@ -448,7 +474,8 @@ func (c *controllerTestSuite) TestDeleteDeeply() { ID: 1, }, }, nil) - err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, true) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, true, false) c.Require().NotNil(err) // reset the mock @@ -463,8 +490,35 @@ func (c *controllerTestSuite) TestDeleteDeeply() { ID: 1, }, }, nil) - err = c.ctl.deleteDeeply(nil, 1, false) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + err = c.ctl.deleteDeeply(nil, 1, false, false) c.Require().Nil(err) + + // reset the mock + c.SetupTest() + + // accessory contains tag + c.artMgr.On("Get", mock.Anything, mock.Anything).Return(&artifact.Artifact{ID: 1}, nil) + c.artMgr.On("Delete", mock.Anything, mock.Anything).Return(nil) + c.tagCtl.On("List").Return([]*tag.Tag{ + { + Tag: model_tag.Tag{ + ID: 1, + }, + }, + }, nil) + c.tagCtl.On("DeleteTags", mock.Anything, mock.Anything).Return(nil) + c.labelMgr.On("RemoveAllFrom", mock.Anything, mock.Anything).Return(nil) + c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{}, nil) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) + c.accMgr.On("DeleteAccessories", mock.Anything, mock.Anything).Return(nil) + c.blobMgr.On("List", mock.Anything, mock.Anything).Return(nil, nil) + c.blobMgr.On("CleanupAssociationsForProject", mock.Anything, mock.Anything, mock.Anything).Return(nil) + c.repoMgr.On("Get", mock.Anything, mock.Anything).Return(&repomodel.RepoRecord{}, nil) + c.artrashMgr.On("Create").Return(0, nil) + err = c.ctl.deleteDeeply(orm.NewContext(nil, &ormtesting.FakeOrmer{}), 1, true, true) + c.Require().Nil(err) + } func (c *controllerTestSuite) TestCopy() { @@ -554,6 +608,7 @@ func (c *controllerTestSuite) TestWalk() { {Digest: "d1", ManifestMediaType: v1.MediaTypeImageManifest}, {Digest: "d2", ManifestMediaType: v1.MediaTypeImageManifest}, }, nil) + c.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) { root := &Artifact{} diff --git a/src/controller/artifact/helper_test.go b/src/controller/artifact/helper_test.go index 646d40440..ca576f4dd 100644 --- a/src/controller/artifact/helper_test.go +++ b/src/controller/artifact/helper_test.go @@ -16,10 +16,13 @@ package artifact import ( "context" + accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model" "testing" "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg/artifact" + "github.com/goharbor/harbor/src/testing/pkg/accessory" + accessorytesting "github.com/goharbor/harbor/src/testing/pkg/accessory" artifacttesting "github.com/goharbor/harbor/src/testing/pkg/artifact" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -29,6 +32,7 @@ type IteratorTestSuite struct { suite.Suite artMgr *artifacttesting.Manager + accMgr *accessory.Manager ctl *controller originalCtl Controller @@ -36,9 +40,13 @@ type IteratorTestSuite struct { func (suite *IteratorTestSuite) SetupSuite() { suite.artMgr = &artifacttesting.Manager{} + suite.accMgr = &accessorytesting.Manager{} suite.originalCtl = Ctl - suite.ctl = &controller{artMgr: suite.artMgr} + suite.ctl = &controller{ + artMgr: suite.artMgr, + accessoryMgr: suite.accMgr, + } Ctl = suite.ctl } @@ -47,6 +55,7 @@ func (suite *IteratorTestSuite) TeardownSuite() { } func (suite *IteratorTestSuite) TestIterator() { + suite.accMgr.On("List", mock.Anything, mock.Anything).Return([]accessorymodel.Accessory{}, nil) q1 := &q.Query{PageNumber: 1, PageSize: 5, Keywords: map[string]interface{}{}} suite.artMgr.On("List", mock.Anything, q1).Return([]*artifact.Artifact{ {ID: 1}, diff --git a/src/controller/artifact/model.go b/src/controller/artifact/model.go index 63aa721e2..f8eab5caf 100644 --- a/src/controller/artifact/model.go +++ b/src/controller/artifact/model.go @@ -19,6 +19,7 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/controller/tag" "github.com/goharbor/harbor/src/lib/encode/repository" + accessoryModel "github.com/goharbor/harbor/src/pkg/accessory/model" "github.com/goharbor/harbor/src/pkg/artifact" "github.com/goharbor/harbor/src/pkg/label/model" ) @@ -26,9 +27,10 @@ import ( // Artifact is the overall view of artifact type Artifact struct { artifact.Artifact - Tags []*tag.Tag `json:"tags"` // the list of tags that attached to the artifact - AdditionLinks map[string]*AdditionLink `json:"addition_links"` // the resource link for build history(image), values.yaml(chart), dependency(chart), etc - Labels []*model.Label `json:"labels"` + Tags []*tag.Tag `json:"tags"` // the list of tags that attached to the artifact + AdditionLinks map[string]*AdditionLink `json:"addition_links"` // the resource link for build history(image), values.yaml(chart), dependency(chart), etc + Labels []*model.Label `json:"labels"` + Accessories []accessoryModel.Accessory `json:"accessories"` } // SetAdditionLink set a addition link @@ -53,7 +55,8 @@ type AdditionLink struct { // Option is used to specify the properties returned when listing/getting artifacts type Option struct { - WithTag bool - TagOption *tag.Option // only works when WithTag is set to true - WithLabel bool + WithTag bool + TagOption *tag.Option // only works when WithTag is set to true + WithLabel bool + WithAccessory bool } diff --git a/src/pkg/accessory/dao/dao.go b/src/pkg/accessory/dao/dao.go index 4d9a2e8fe..b71df1cd0 100644 --- a/src/pkg/accessory/dao/dao.go +++ b/src/pkg/accessory/dao/dao.go @@ -33,8 +33,8 @@ type DAO interface { Create(ctx context.Context, accessory *Accessory) (id int64, err error) // Delete the accessory specified by ID Delete(ctx context.Context, id int64) (err error) - // DeleteOfArtifact deletes all accessory attached to the artifact - DeleteOfArtifact(ctx context.Context, subArtifactID int64) (err error) + // DeleteAccessories deletes accessories by query + DeleteAccessories(ctx context.Context, query *q.Query) (int64, error) } // New returns an instance of the default DAO @@ -116,15 +116,10 @@ func (d *dao) Delete(ctx context.Context, id int64) error { return nil } -func (d *dao) DeleteOfArtifact(ctx context.Context, subArtifactID int64) error { - qs, err := orm.QuerySetter(ctx, &Accessory{}, &q.Query{ - Keywords: map[string]interface{}{ - "SubjectArtifactID": subArtifactID, - }, - }) +func (d *dao) DeleteAccessories(ctx context.Context, query *q.Query) (int64, error) { + qs, err := orm.QuerySetter(ctx, &Accessory{}, query) if err != nil { - return err + return 0, err } - _, err = qs.Delete() - return err + return qs.Delete() } diff --git a/src/pkg/accessory/dao/dao_test.go b/src/pkg/accessory/dao/dao_test.go index 1fb1b8f02..12ecb3dc4 100644 --- a/src/pkg/accessory/dao/dao_test.go +++ b/src/pkg/accessory/dao/dao_test.go @@ -246,7 +246,11 @@ func (d *daoTestSuite) TestDeleteOfArtifact() { d.Require().Nil(err) d.Require().Len(accs, 2) - err = d.dao.DeleteOfArtifact(d.ctx, subArtID) + _, err = d.dao.DeleteAccessories(d.ctx, &q.Query{ + Keywords: map[string]interface{}{ + "SubjectArtifactID": subArtID, + }, + }) d.Require().Nil(err) accs, err = d.dao.List(d.ctx, &q.Query{ diff --git a/src/pkg/accessory/manager.go b/src/pkg/accessory/manager.go index d12c2ff87..91aa3b1bb 100644 --- a/src/pkg/accessory/manager.go +++ b/src/pkg/accessory/manager.go @@ -42,8 +42,8 @@ type Manager interface { Create(ctx context.Context, accessory model.AccessoryData) (id int64, err error) // Delete the tag specified by ID Delete(ctx context.Context, id int64) (err error) - // DeleteOfArtifact deletes all tags attached to the artifact - DeleteOfArtifact(ctx context.Context, artifactID int64) (err error) + // DeleteAccessories deletes accessories according to the query + DeleteAccessories(ctx context.Context, q *q.Query) (err error) } // NewManager returns an instance of the default manager @@ -116,6 +116,7 @@ func (m *manager) Delete(ctx context.Context, id int64) error { return m.dao.Delete(ctx, id) } -func (m *manager) DeleteOfArtifact(ctx context.Context, artifactID int64) error { - return m.dao.DeleteOfArtifact(ctx, artifactID) +func (m *manager) DeleteAccessories(ctx context.Context, q *q.Query) error { + _, err := m.dao.DeleteAccessories(ctx, q) + return err } diff --git a/src/pkg/accessory/manager_test.go b/src/pkg/accessory/manager_test.go index 0ef1cf66f..98a9455c6 100644 --- a/src/pkg/accessory/manager_test.go +++ b/src/pkg/accessory/manager_test.go @@ -15,6 +15,7 @@ package accessory import ( + "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg/accessory/dao" "github.com/goharbor/harbor/src/pkg/accessory/model" "github.com/goharbor/harbor/src/testing/mock" @@ -90,8 +91,8 @@ func (m *managerTestSuite) TestCount() { } func (m *managerTestSuite) TestDeleteOfArtifact() { - mock.OnAnything(m.dao, "DeleteOfArtifact").Return(nil) - err := m.mgr.DeleteOfArtifact(nil, 1) + mock.OnAnything(m.dao, "DeleteAccessories").Return(int64(1), nil) + err := m.mgr.DeleteAccessories(nil, q.New(q.KeyWords{"ArtifactID": 1})) m.Require().Nil(err) m.dao.AssertExpectations(m.T()) } diff --git a/src/pkg/accessory/model/accessory.go b/src/pkg/accessory/model/accessory.go index 9f36a0a85..c112970f0 100644 --- a/src/pkg/accessory/model/accessory.go +++ b/src/pkg/accessory/model/accessory.go @@ -76,6 +76,8 @@ type AccessoryData struct { type Accessory interface { RefProvider RefIdentifier + // Define whether shows in the artifact list response. + Display() bool GetData() AccessoryData } diff --git a/src/pkg/accessory/model/base/base.go b/src/pkg/accessory/model/base/base.go index 86c56e306..dd4bc37a7 100644 --- a/src/pkg/accessory/model/base/base.go +++ b/src/pkg/accessory/model/base/base.go @@ -40,6 +40,11 @@ func (a *Default) IsHard() bool { return false } +// Display ... +func (a *Default) Display() bool { + return false +} + // GetData ... func (a *Default) GetData() model.AccessoryData { return a.Data diff --git a/src/pkg/accessory/model/base/base_test.go b/src/pkg/accessory/model/base/base_test.go index 06d48e51d..43e334f94 100644 --- a/src/pkg/accessory/model/base/base_test.go +++ b/src/pkg/accessory/model/base/base_test.go @@ -11,10 +11,12 @@ type BaseTestSuite struct { htesting.Suite accessory model.Accessory digest string + subDigest string } func (suite *BaseTestSuite) SetupSuite() { suite.digest = suite.DigestString() + suite.subDigest = suite.DigestString() suite.accessory, _ = model.New(model.TypeNone, model.AccessoryData{ ArtifactID: 1, @@ -60,6 +62,10 @@ func (suite *BaseTestSuite) TestIsHard() { suite.False(suite.accessory.IsHard()) } +func (suite *BaseTestSuite) TestDisplay() { + suite.False(suite.accessory.Display()) +} + func TestCacheTestSuite(t *testing.T) { suite.Run(t, new(BaseTestSuite)) } diff --git a/src/pkg/accessory/model/cosign/cosign_test.go b/src/pkg/accessory/model/cosign/cosign_test.go index 4beff5a68..9c04b9010 100644 --- a/src/pkg/accessory/model/cosign/cosign_test.go +++ b/src/pkg/accessory/model/cosign/cosign_test.go @@ -60,6 +60,10 @@ func (suite *CosignTestSuite) TestIsHard() { suite.True(suite.accessory.IsHard()) } +func (suite *CosignTestSuite) TestDisplay() { + suite.False(suite.accessory.Display()) +} + func TestCacheTestSuite(t *testing.T) { suite.Run(t, new(CosignTestSuite)) } diff --git a/src/server/v2.0/handler/artifact.go b/src/server/v2.0/handler/artifact.go index 89a8f73d1..2690023b4 100644 --- a/src/server/v2.0/handler/artifact.go +++ b/src/server/v2.0/handler/artifact.go @@ -35,6 +35,7 @@ import ( "github.com/goharbor/harbor/src/controller/tag" "github.com/goharbor/harbor/src/lib" "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/pkg/accessory" "github.com/goharbor/harbor/src/pkg/notification" "github.com/goharbor/harbor/src/pkg/scan/report" "github.com/goharbor/harbor/src/server/v2.0/handler/assembler" @@ -46,6 +47,7 @@ import ( func newArtifactAPI() *artifactAPI { return &artifactAPI{ + accMgr: accessory.Mgr, artCtl: artifact.Ctl, proCtl: project.Ctl, repoCtl: repository.Ctl, @@ -56,6 +58,7 @@ func newArtifactAPI() *artifactAPI { type artifactAPI struct { BaseAPI + accMgr accessory.Manager artCtl artifact.Controller proCtl project.Controller repoCtl repository.Controller @@ -85,7 +88,7 @@ func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListAr // set option option := option(params.WithTag, params.WithImmutableStatus, - params.WithLabel, params.WithSignature) + params.WithLabel, params.WithSignature, params.WithAccessory) // get the total count of artifacts total, err := a.artCtl.Count(ctx, query) @@ -119,7 +122,7 @@ func (a *artifactAPI) GetArtifact(ctx context.Context, params operation.GetArtif } // set option option := option(params.WithTag, params.WithImmutableStatus, - params.WithLabel, params.WithSignature) + params.WithLabel, params.WithSignature, params.WithAccessory) // get the artifact artifact, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, option) @@ -333,6 +336,39 @@ func (a *artifactAPI) ListTags(ctx context.Context, params operation.ListTagsPar WithPayload(ts) } +func (a *artifactAPI) ListAccessories(ctx context.Context, params operation.ListAccessoriesParams) middleware.Responder { + if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionList, rbac.ResourceAccessory); err != nil { + return a.SendError(ctx, err) + } + // set query + query, err := a.BuildQuery(ctx, params.Q, params.Sort, params.Page, params.PageSize) + if err != nil { + return a.SendError(ctx, err) + } + + artifact, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, nil) + if err != nil { + return a.SendError(ctx, err) + } + query.Keywords["SubjectArtifactID"] = artifact.ID + + // list accessories according to the query + accs, err := a.accMgr.List(ctx, query) + if err != nil { + return a.SendError(ctx, err) + } + total := len(accs) + + var res []*models.Accessory + for _, acc := range accs { + res = append(res, model.NewAccessory(acc.GetData()).ToSwagger()) + } + return operation.NewListAccessoriesOK(). + WithXTotalCount(int64(total)). + WithLink(a.Links(ctx, params.HTTPRequest.URL, int64(total), query.PageNumber, query.PageSize).String()). + WithPayload(res) +} + func (a *artifactAPI) GetVulnerabilitiesAddition(ctx context.Context, params operation.GetVulnerabilitiesAdditionParams) middleware.Responder { if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionRead, rbac.ResourceArtifactAddition); err != nil { return a.SendError(ctx, err) @@ -424,16 +460,21 @@ func (a *artifactAPI) RemoveLabel(ctx context.Context, params operation.RemoveLa return operation.NewRemoveLabelOK() } -func option(withTag, withImmutableStatus, withLabel, withSignature *bool) *artifact.Option { +func option(withTag, withImmutableStatus, withLabel, withSignature, withAccessory *bool) *artifact.Option { option := &artifact.Option{ - WithTag: true, // return the tag by default - WithLabel: lib.BoolValue(withLabel), + WithTag: true, // return the tag by default + WithLabel: lib.BoolValue(withLabel), + WithAccessory: true, // return the accessory by default } if withTag != nil { option.WithTag = *(withTag) } + if withAccessory != nil { + option.WithAccessory = *(withAccessory) + } + if option.WithTag { option.TagOption = &tag.Option{ WithImmutableStatus: lib.BoolValue(withImmutableStatus), diff --git a/src/server/v2.0/handler/model/accessory.go b/src/server/v2.0/handler/model/accessory.go new file mode 100644 index 000000000..24e623709 --- /dev/null +++ b/src/server/v2.0/handler/model/accessory.go @@ -0,0 +1,31 @@ +package model + +import ( + "github.com/go-openapi/strfmt" + "github.com/goharbor/harbor/src/pkg/accessory/model" + "github.com/goharbor/harbor/src/server/v2.0/models" +) + +// Accessory model +type Accessory struct { + model.AccessoryData +} + +// ToSwagger converts the label to the swagger model +func (a *Accessory) ToSwagger() *models.Accessory { + return &models.Accessory{ + ID: a.ID, + ArtifactID: a.ArtifactID, + SubjectArtifactID: a.SubArtifactID, + Size: a.Size, + Digest: a.Digest, + Type: a.Type, + Icon: "", + CreationTime: strfmt.DateTime(a.CreatTime), + } +} + +// NewAccessory ... +func NewAccessory(a model.AccessoryData) *Accessory { + return &Accessory{AccessoryData: a} +} diff --git a/src/server/v2.0/handler/model/artifact.go b/src/server/v2.0/handler/model/artifact.go index a08a2534e..70fefa4d6 100644 --- a/src/server/v2.0/handler/model/artifact.go +++ b/src/server/v2.0/handler/model/artifact.go @@ -51,6 +51,9 @@ func (a *Artifact) ToSwagger() *models.Artifact { for _, reference := range a.References { art.References = append(art.References, NewReference(reference).ToSwagger()) } + for _, acc := range a.Accessories { + art.Accessories = append(art.Accessories, NewAccessory(acc.GetData()).ToSwagger()) + } for _, tag := range a.Tags { art.Tags = append(art.Tags, NewTag(tag).ToSwagger()) } diff --git a/src/testing/pkg/accessory/dao/dao.go b/src/testing/pkg/accessory/dao/dao.go index 9fb3803c1..4a5d91d34 100644 --- a/src/testing/pkg/accessory/dao/dao.go +++ b/src/testing/pkg/accessory/dao/dao.go @@ -72,18 +72,25 @@ func (_m *DAO) Delete(ctx context.Context, id int64) error { return r0 } -// DeleteOfArtifact provides a mock function with given fields: ctx, subArtifactID -func (_m *DAO) DeleteOfArtifact(ctx context.Context, subArtifactID int64) error { - ret := _m.Called(ctx, subArtifactID) +// DeleteAccessories provides a mock function with given fields: ctx, query +func (_m *DAO) DeleteAccessories(ctx context.Context, query *q.Query) (int64, error) { + ret := _m.Called(ctx, query) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { - r0 = rf(ctx, subArtifactID) + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(int64) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // Get provides a mock function with given fields: ctx, id diff --git a/src/testing/pkg/accessory/manager.go b/src/testing/pkg/accessory/manager.go new file mode 100644 index 000000000..5be375bba --- /dev/null +++ b/src/testing/pkg/accessory/manager.go @@ -0,0 +1,133 @@ +// Code generated by mockery v2.1.0. DO NOT EDIT. + +package accessory + +import ( + context "context" + + model "github.com/goharbor/harbor/src/pkg/accessory/model" + mock "github.com/stretchr/testify/mock" + + q "github.com/goharbor/harbor/src/lib/q" +) + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// Count provides a mock function with given fields: ctx, query +func (_m *Manager) Count(ctx context.Context, query *q.Query) (int64, error) { + ret := _m.Called(ctx, query) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) int64); ok { + r0 = rf(ctx, query) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: ctx, _a1 +func (_m *Manager) Create(ctx context.Context, _a1 model.AccessoryData) (int64, error) { + ret := _m.Called(ctx, _a1) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, model.AccessoryData) int64); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, model.AccessoryData) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Manager) Delete(ctx context.Context, id int64) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteAccessories provides a mock function with given fields: ctx, _a1 +func (_m *Manager) DeleteAccessories(ctx context.Context, _a1 *q.Query) error { + ret := _m.Called(ctx, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: ctx, id +func (_m *Manager) Get(ctx context.Context, id int64) (model.Accessory, error) { + ret := _m.Called(ctx, id) + + var r0 model.Accessory + if rf, ok := ret.Get(0).(func(context.Context, int64) model.Accessory); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(model.Accessory) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, query +func (_m *Manager) List(ctx context.Context, query *q.Query) ([]model.Accessory, error) { + ret := _m.Called(ctx, query) + + var r0 []model.Accessory + if rf, ok := ret.Get(0).(func(context.Context, *q.Query) []model.Accessory); ok { + r0 = rf(ctx, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Accessory) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *q.Query) error); ok { + r1 = rf(ctx, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/src/testing/pkg/accessory/model/accessory.go b/src/testing/pkg/accessory/model/accessory.go index a35e1878a..140c7a4ce 100644 --- a/src/testing/pkg/accessory/model/accessory.go +++ b/src/testing/pkg/accessory/model/accessory.go @@ -12,6 +12,20 @@ type Accessory struct { mock.Mock } +// Display provides a mock function with given fields: +func (_m *Accessory) Display() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // GetData provides a mock function with given fields: func (_m *Accessory) GetData() model.AccessoryData { ret := _m.Called() diff --git a/src/testing/pkg/pkg.go b/src/testing/pkg/pkg.go index 82815f841..9568bea65 100644 --- a/src/testing/pkg/pkg.go +++ b/src/testing/pkg/pkg.go @@ -55,3 +55,4 @@ package pkg //go:generate mockery --case snake --dir ../../pkg/joblog/dao --name DAO --output ./joblog/dao --outpkg dao //go:generate mockery --case snake --dir ../../pkg/accessory/model --name Accessory --output ./accessory/model --outpkg model //go:generate mockery --case snake --dir ../../pkg/accessory/dao --name DAO --output ./accessory/dao --outpkg dao +//go:generate mockery --case snake --dir ../../pkg/accessory --name Manager --output ./accessory --outpkg accessory