Update tags related APIs (#11435)

* Update tags related APIs

1. Remove API for listing tags of repository
2. Add API for listing tags of artifact
3. Support filter artifact by tag name

Signed-off-by: Wenkai Yin <yinw@vmware.com>

* [OCI] modify artifact tag name check
1. switch api get tag list
2. modify artifact tag name check
Signed-off-by: Yogi_Wang <yawang@vmware.com>

Co-authored-by: Yogi_Wang <yawang@vmware.com>
This commit is contained in:
Wenkai Yin(尹文开) 2020-04-07 16:28:51 +08:00 committed by GitHub
parent e064bd4c01
commit 88ae9c458c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 180 additions and 195 deletions

View File

@ -138,7 +138,7 @@ paths:
/projects/{project_name}/repositories/{repository_name}/artifacts:
get:
summary: List artifacts
description: List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to
description: List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to
tags:
- artifact
operationId: listArtifacts
@ -401,6 +401,56 @@ paths:
$ref: '#/responses/409'
'500':
$ref: '#/responses/500'
get:
summary: List tags
description: List tags of the specific artifact
tags:
- artifact
operationId: listTags
parameters:
- $ref: '#/parameters/requestId'
- $ref: '#/parameters/projectName'
- $ref: '#/parameters/repositoryName'
- $ref: '#/parameters/reference'
- $ref: '#/parameters/query'
- $ref: '#/parameters/page'
- $ref: '#/parameters/pageSize'
- name: with_signature
in: query
description: Specify whether the signature is included inside the returning tags
type: boolean
required: false
default: false
- name: with_immutable_status
in: query
description: Specify whether the immutable status is included inside the returning tags
type: boolean
required: false
default: false
responses:
'200':
description: Success
headers:
X-Total-Count:
description: The total count of tags
type: integer
Link:
description: Link refers to the previous page and next page
type: string
schema:
type: array
items:
$ref: '#/definitions/Tag'
'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}/tags/{tag_name}:
delete:
summary: Delete tag
@ -526,56 +576,6 @@ paths:
$ref: '#/responses/409'
'500':
$ref: '#/responses/500'
/projects/{project_name}/repositories/{repository_name}/tags:
get:
summary: List tags
description: List tags under the specific project and repository.
tags:
- tag
operationId: listTags
parameters:
- $ref: '#/parameters/requestId'
- $ref: '#/parameters/projectName'
- $ref: '#/parameters/repositoryName'
- $ref: '#/parameters/query'
- $ref: '#/parameters/page'
- $ref: '#/parameters/pageSize'
- name: with_signature
in: query
description: Specify whether the signature is included inside the returning tags
type: boolean
required: false
default: false
- name: with_immutable_status
in: query
description: Specify whether the immutable status is included inside the returning tags
type: boolean
required: false
default: false
responses:
'200':
description: Success
headers:
X-Total-Count:
description: The total count of tags
type: integer
Link:
description: Link refers to the previous page and next page
type: string
schema:
type: array
items:
$ref: '#/definitions/Tag'
'400':
$ref: '#/responses/400'
'401':
$ref: '#/responses/401'
'403':
$ref: '#/responses/403'
'404':
$ref: '#/responses/404'
'500':
$ref: '#/responses/500'
/audit-logs:
get:
summary: Get recent logs of the projects which the user is a member of

View File

@ -318,7 +318,10 @@ func setTagQuery(qs beegoorm.QuerySeter, query *q.Query) (beegoorm.QuerySeter, e
qs = qs.FilterRaw("id", sql)
return qs, nil
}
// exact match, only handle "*" for listing tagged artifacts and "nil" for listing untagged artifacts
// exact match:
// "*" for listing tagged artifacts
// "nil" for listing untagged artifacts
// others for get the artifact with the specified tag
s, ok := tags.(string)
if ok {
if s == "*" {
@ -329,9 +332,15 @@ func setTagQuery(qs beegoorm.QuerySeter, query *q.Query) (beegoorm.QuerySeter, e
qs = qs.FilterRaw("id", untagged)
return qs, nil
}
sql := fmt.Sprintf(`IN (
SELECT DISTINCT art.id FROM artifact art
JOIN tag ON art.id=tag.artifact_id
WHERE tag.name = '%s')`, orm.Escape(s))
qs = qs.FilterRaw("id", sql)
return qs, nil
}
return qs, errors.New(nil).WithCode(errors.BadRequestCode).
WithMessage(`the value of "tags" query can only be fuzzy match value or exact match value with "*" and "nil"`)
WithMessage(`the value of "tags" query can only be fuzzy match value or exact match value`)
}
// handle query string: q=labels=(1 2 3)

View File

@ -169,18 +169,18 @@ func (d *daoTestSuite) TestCount() {
d.Require().Nil(err)
d.Equal(totalOfAll-1, totalOfUnTagged)
// invalid tags value
_, err = d.dao.Count(d.ctx, &q.Query{
// specific tag value
total, err := d.dao.Count(d.ctx, &q.Query{
Keywords: map[string]interface{}{
"RepositoryID": 1,
"Tags": "invalid_value",
"Tags": "latest",
},
})
d.Require().NotNil(err)
d.True(errors.IsErr(err, errors.BadRequestCode))
d.Require().Nil(err)
d.Equal(int64(1), total)
// query by repository ID and digest
total, err := d.dao.Count(d.ctx, &q.Query{
total, err = d.dao.Count(d.ctx, &q.Query{
Keywords: map[string]interface{}{
"RepositoryID": 1,
"Digest": "parent_digest",

View File

@ -18,10 +18,12 @@
<label class="clr-control-container" [class.clr-error]="isTagNameExist || name.hasError('pattern')">
<input clrInput type="text" id="name" name="name" required size="20" autocomplete="off"
[(ngModel)]="newTagName.name" #name="ngModel" pattern="^[\w][\w.-]{0,127}$" (keyup)="existValid(newTagName.name)">
<clr-control-error class="position-ab" *ngIf="isTagNameExist">
<span class="spinner spinner-inline spinner-tag" [hidden]="!tagNameCheckOnGoing"></span>
<clr-control-error class="position-ab white-space-nowrap" *ngIf="isTagNameExist">
{{'TAG.NAME_ALREADY_EXISTS' | translate }}
</clr-control-error>
<clr-control-error class="position-ab" *ngIf="name.hasError('pattern')">
<clr-control-error class="position-ab white-space-nowrap" *ngIf="name.hasError('pattern')">
{{'RETAG.TIP_TAG' | translate }}
</clr-control-error>
</label>
@ -31,7 +33,7 @@
'BUTTON.CANCEL' | translate }}
</button>
<button type="submit" class="btn btn-sm btn-primary" (click)="saveAddTag()"
[disabled]="isTagNameExist || !newTagName.name ||tagForm.invalid">{{
[disabled]="isTagNameExist || tagNameCheckOnGoing || !newTagName.name ||tagForm.invalid">{{
'BUTTON.OK' | translate }}
</button>
</label>

View File

@ -4,6 +4,7 @@
}
.clr-control-container {
margin-bottom: 1rem;
position: relative;
}
.btn.remove-btn {
border: none;
@ -25,9 +26,17 @@
.position-ab {
position: absolute;
}
.white-space-nowrap {
white-space: nowrap;
}
.refresh-btn {
float: right;
margin-top: 15px;
margin-right: 20px;
cursor: pointer;
}
.spinner-tag {
position: absolute;
right: -.7rem;
top: 1.2rem;
}

View File

@ -11,7 +11,6 @@ import { ArtifactService } from '../../../../../../ng-swagger-gen/services/artif
import { OperationService } from "../../../../../lib/components/operation/operation.service";
import { CURRENT_BASE_HREF } from "../../../../../lib/utils/utils";
import { USERSTATICPERMISSION, UserPermissionService, UserPermissionDefaultService } from '../../../../../lib/services';
import { TagService } from '../../../../../../ng-swagger-gen/services/tag.service';
import { delay } from 'rxjs/operators';
@ -21,13 +20,11 @@ describe('ArtifactTagComponent', () => {
const mockErrorHandler = {
error: () => {}
};
const mockTagService = {
listTagsResponse: () => of({headers: null, body: []}).pipe(delay(0)),
listTags: () => of([]),
};
const mockArtifactService = {
createTag: () => of([]),
deleteTag: () => of(null),
listTagsResponse: () => of([]).pipe(delay(0))
};
const config: IServiceConfig = {
repositoryBaseEndpoint: CURRENT_BASE_HREF + "/repositories/testing"
@ -54,7 +51,6 @@ describe('ArtifactTagComponent', () => {
{ provide: SERVICE_CONFIG, useValue: config },
{ provide: mockErrorHandler, useValue: ErrorHandler },
{ provide: ArtifactService, useValue: mockArtifactService },
{ provide: TagService, useValue: mockTagService },
{ provide: UserPermissionService, useClass: UserPermissionDefaultService },
{ provide: OperationService },
]

View File

@ -1,6 +1,6 @@
import { Component, OnInit, Input, ViewChild, Output, EventEmitter } from '@angular/core';
import { Observable, of, forkJoin } from 'rxjs';
import { map, catchError, finalize } from 'rxjs/operators';
import { Component, OnInit, Input, ViewChild, OnDestroy } from '@angular/core';
import { Observable, of, forkJoin, Subject, Subscription } from 'rxjs';
import { map, catchError, finalize, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { NgForm } from '@angular/forms';
import { AVAILABLE_TIME } from "../../artifact-list-page/artifact-list/artifact-list-tab/artifact-list-tab.component";
@ -16,7 +16,6 @@ import { operateChanges, OperateInfo, OperationState } from "../../../../../lib/
import { errorHandler } from "../../../../../lib/utils/shared/shared.utils";
import { ArtifactFront as Artifact } from "../artifact";
import { ArtifactService } from '../../../../../../ng-swagger-gen/services/artifact.service';
import { TagService } from '../../../../../../ng-swagger-gen/services/tag.service';
import { Tag } from '../../../../../../ng-swagger-gen/models/tag';
import {
UserPermissionService, USERSTATICPERMISSION
@ -33,7 +32,7 @@ class InitTag {
styleUrls: ['./artifact-tag.component.scss']
})
export class ArtifactTagComponent implements OnInit {
export class ArtifactTagComponent implements OnInit, OnDestroy {
@Input() artifactDetails: Artifact;
@Input() projectName: string;
@Input() projectId: number;
@ -57,10 +56,12 @@ export class ArtifactTagComponent implements OnInit {
currentTags: Tag[] = [];
pageSize: number = DEFAULT_PAGE_SIZE;
currentPage = 1;
tagNameChecker: Subject<string> = new Subject<string>();
tagNameCheckSub: Subscription;
tagNameCheckOnGoing = false;
constructor(
private operationService: OperationService,
private artifactService: ArtifactService,
private tagService: TagService,
private translateService: TranslateService,
private userPermissionService: UserPermissionService,
private errorHandlerService: ErrorHandler
@ -68,7 +69,39 @@ export class ArtifactTagComponent implements OnInit {
) { }
ngOnInit() {
this.getImagePermissionRule(this.projectId);
this.getAllTags();
this.invalidCreateTag();
}
checkTagName(name) {
let listArtifactParams: ArtifactService.ListArtifactsParams = {
projectName: this.projectName,
repositoryName: this.repositoryName,
withLabel: true,
withScanOverview: true,
withTag: true,
q: encodeURIComponent(`tags=${name}`)
};
return this.artifactService.listArtifacts(listArtifactParams)
.pipe(finalize(() => this.tagNameCheckOnGoing = false));
}
invalidCreateTag() {
if (!this.tagNameCheckSub) {
this.tagNameCheckSub = this.tagNameChecker
.pipe(debounceTime(200))
.pipe(distinctUntilChanged())
.pipe(switchMap(name => {
this.tagNameCheckOnGoing = true;
this.isTagNameExist = false;
return this.checkTagName(name);
}))
.subscribe(response => {
// tag existing
if (response && response.length) {
this.isTagNameExist = true;
}
}, error => {
this.errorHandlerService.error(error);
});
}
}
getCurrentArtifactTags(state: ClrDatagridStateInterface) {
if (!state || !state.page) {
@ -76,16 +109,16 @@ export class ArtifactTagComponent implements OnInit {
}
let pageNumber: number = calculatePage(state);
if (pageNumber <= 0) { pageNumber = 1; }
let params: TagService.ListTagsParams = {
let params: ArtifactService.ListTagsParams = {
projectName: this.projectName,
repositoryName: this.repositoryName,
reference: this.artifactDetails.digest,
page: pageNumber,
withSignature: true,
withImmutableStatus: true,
pageSize: this.pageSize,
q: encodeURIComponent(`artifact_id=${this.artifactDetails.id}`)
pageSize: this.pageSize
};
this.tagService.listTagsResponse(params).pipe(finalize(() => {
this.artifactService.listTagsResponse(params).pipe(finalize(() => {
this.loading = false;
})).subscribe(res => {
if (res.headers) {
@ -99,17 +132,6 @@ export class ArtifactTagComponent implements OnInit {
this.errorHandlerService.error(error);
});
}
getAllTags() {
let params: TagService.ListTagsParams = {
projectName: this.projectName,
repositoryName: this.repositoryName
};
this.tagService.listTags(params).subscribe(res => {
this.allTags = res;
}, error => {
this.errorHandlerService.error(error);
});
}
getImagePermissionRule(projectId: number): void {
const permissions = [
{ resource: USERSTATICPERMISSION.REPOSITORY_TAG.KEY, action: USERSTATICPERMISSION.REPOSITORY_TAG.VALUE.DELETE },
@ -142,7 +164,6 @@ export class ArtifactTagComponent implements OnInit {
this.artifactService.createTag(createTagParams).subscribe(res => {
this.newTagformShow = false;
this.newTagName = new InitTag();
this.getAllTags();
this.currentPage = 1;
let st: ClrDatagridStateInterface = { page: {from: 0, to: this.pageSize - 1, size: this.pageSize} };
this.getCurrentArtifactTags(st);
@ -249,15 +270,9 @@ export class ArtifactTagComponent implements OnInit {
}
existValid(name) {
this.isTagNameExist = false;
if (this.allTags) {
this.allTags.forEach(tag => {
if (tag.name === name) {
this.isTagNameExist = true;
}
});
if (name) {
this.tagNameChecker.next(name);
}
}
toggleTagListOpenOrClose() {
this.openTag = !this.openTag;
@ -272,4 +287,7 @@ export class ArtifactTagComponent implements OnInit {
let st: ClrDatagridStateInterface = { page: {from: 0, to: this.pageSize - 1, size: this.pageSize} };
this.getCurrentArtifactTags(st);
}
ngOnDestroy(): void {
this.tagNameCheckSub.unsubscribe();
}
}

View File

@ -147,7 +147,6 @@ func (a *artifactAPI) DeleteArtifact(ctx context.Context, params operation.Delet
return operation.NewDeleteArtifactOK()
}
// TODO immutable, quota, readonly middlewares should cover this API
func (a *artifactAPI) CopyArtifact(ctx context.Context, params operation.CopyArtifactParams) middleware.Responder {
if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionCreate, rbac.ResourceArtifact); err != nil {
return a.SendError(ctx, err)
@ -224,7 +223,7 @@ func (a *artifactAPI) CreateTag(ctx context.Context, params operation.CreateTagP
AttachedArtifact: &art.Artifact,
})
// TODO as we provide no API for get the single tag, ignore setting the location header here
// as we provide no API for get the single tag, ignore setting the location header here
return operation.NewCreateTagCreated()
}
@ -266,6 +265,52 @@ func (a *artifactAPI) DeleteTag(ctx context.Context, params operation.DeleteTagP
return operation.NewDeleteTagOK()
}
func (a *artifactAPI) ListTags(ctx context.Context, params operation.ListTagsParams) middleware.Responder {
if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionList, rbac.ResourceTag); err != nil {
return a.SendError(ctx, err)
}
// set query
query, err := a.BuildQuery(ctx, params.Q, 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["ArtifactID"] = artifact.ID
// get the total count of tags
total, err := a.tagCtl.Count(ctx, query)
if err != nil {
return a.SendError(ctx, err)
}
// set option
option := &tag.Option{}
if params.WithSignature != nil {
option.WithSignature = *params.WithSignature
}
if params.WithImmutableStatus != nil {
option.WithImmutableStatus = *params.WithImmutableStatus
}
// list tags according to the query and option
tags, err := a.tagCtl.List(ctx, query, option)
if err != nil {
return a.SendError(ctx, err)
}
var ts []*models.Tag
for _, tag := range tags {
ts = append(ts, tag.ToSwagger())
}
return operation.NewListTagsOK().
WithXTotalCount(total).
WithLink(a.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()).
WithPayload(ts)
}
func (a *artifactAPI) GetAddition(ctx context.Context, params operation.GetAdditionParams) middleware.Responder {
if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionRead, rbac.ResourceArtifactAddition); err != nil {
return a.SendError(ctx, err)

View File

@ -34,7 +34,6 @@ func New() http.Handler {
AuditlogAPI: newAuditLogAPI(),
ScanAPI: newScanAPI(),
ProjectAPI: newProjectAPI(),
TagAPI: newTagAPI(),
})
if err != nil {
log.Fatal(err)

View File

@ -1,93 +0,0 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handler
import (
"context"
"fmt"
"github.com/go-openapi/runtime/middleware"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/controller/repository"
"github.com/goharbor/harbor/src/controller/tag"
"github.com/goharbor/harbor/src/server/v2.0/models"
operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/tag"
)
func newTagAPI() *tagAPI {
return &tagAPI{
repoCtl: repository.Ctl,
tagCtl: tag.Ctl,
}
}
type tagAPI struct {
BaseAPI
repoCtl repository.Controller
tagCtl tag.Controller
}
func (t *tagAPI) Prepare(ctx context.Context, operation string, params interface{}) middleware.Responder {
if err := unescapePathParams(params, "RepositoryName"); err != nil {
t.SendError(ctx, err)
}
return nil
}
func (t *tagAPI) ListTags(ctx context.Context, params operation.ListTagsParams) middleware.Responder {
if err := t.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionList, rbac.ResourceTag); err != nil {
return t.SendError(ctx, err)
}
// set query
query, err := t.BuildQuery(ctx, params.Q, params.Page, params.PageSize)
if err != nil {
return t.SendError(ctx, err)
}
repository, err := t.repoCtl.GetByName(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName))
if err != nil {
return t.SendError(ctx, err)
}
query.Keywords["RepositoryID"] = repository.RepositoryID
// get the total count of tags
total, err := t.tagCtl.Count(ctx, query)
if err != nil {
return t.SendError(ctx, err)
}
// set option
option := &tag.Option{}
if params.WithSignature != nil {
option.WithSignature = *params.WithSignature
}
if params.WithImmutableStatus != nil {
option.WithImmutableStatus = *params.WithImmutableStatus
}
// list tags according to the query and option
tags, err := t.tagCtl.List(ctx, query, option)
if err != nil {
return t.SendError(ctx, err)
}
var ts []*models.Tag
for _, tag := range tags {
ts = append(ts, tag.ToSwagger())
}
return operation.NewListTagsOK().
WithXTotalCount(total).
WithLink(t.Links(ctx, params.HTTPRequest.URL, total, query.PageNumber, query.PageSize).String()).
WithPayload(ts)
}