diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 638ce5cbc..795bcf2f8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1131,6 +1131,33 @@ paths: $ref: '#/definitions/DetailedTag' '500': description: Unexpected internal errors. + post: + summary: Retag an image + description: > + This endpoint tags an existing image with another tag in this repo, source images + can be in different repos or projects. + parameters: + - name: request + in: body + description: Request to give source image and target tag. + required: true + schema: + $ref: '#/definitions/RetagReq' + tags: + - Products + responses: + '200': + description: Image retag successfully. + '400': + description: Invalid image values provided. + '401': + description: User has no permission to the source project or destination project. + '404': + description: Project or repository not found. + '409': + description: Target tag already exists. + '500': + description: Unexpected internal errors. '/repositories/{repo_name}/tags/{tag}/labels': get: summary: Get labels of an image. @@ -2966,6 +2993,18 @@ definitions: type: array items: $ref: '#/definitions/SearchResult' + RetagReq: + type: object + properties: + tag: + description: new tag to be created + type: string + src_image: + description: Source image to be retagged, e.g. 'stage/app:v1.0' + type: string + override: + description: If target tag already exists, whether to override it + type: boolean SearchRepository: type: object properties: diff --git a/src/common/dao/project.go b/src/common/dao/project.go index c39c3e79a..80751a35f 100644 --- a/src/common/dao/project.go +++ b/src/common/dao/project.go @@ -113,6 +113,12 @@ func GetProjectByName(name string) (*models.Project, error) { return &p[0], nil } +// ProjectExistsByName returns whether the project exists according to its name. +func ProjectExistsByName(name string) bool { + o := GetOrmer() + return o.QueryTable("project").Filter("name", name).Exist() +} + // GetTotalOfProjects returns the total count of projects // according to the query conditions func GetTotalOfProjects(query *models.ProjectQueryParam) (int64, error) { diff --git a/src/common/dao/project_test.go b/src/common/dao/project_test.go index 9b0f0abdb..7358840b9 100644 --- a/src/common/dao/project_test.go +++ b/src/common/dao/project_test.go @@ -235,3 +235,30 @@ func TestGetRolesByLDAPGroup(t *testing.T) { }) } } + +func TestProjetExistsByName(t *testing.T) { + name := "project_exist_by_name_test" + exist := ProjectExistsByName(name) + if exist { + t.Errorf("project %s expected to be not exist", name) + } + + project := models.Project{ + OwnerID: currentUser.UserID, + Name: name, + } + id, err := AddProject(project) + if err != nil { + t.Fatalf("failed to add project: %v", err) + } + defer func() { + if err := delProjPermanent(id); err != nil { + t.Errorf("failed to clear up project %d: %v", id, err) + } + }() + + exist = ProjectExistsByName(name) + if !exist { + t.Errorf("project %s expected to be exist", name) + } +} diff --git a/src/common/models/retag.go b/src/common/models/retag.go new file mode 100644 index 000000000..d684922b8 --- /dev/null +++ b/src/common/models/retag.go @@ -0,0 +1,52 @@ +// 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 models + +import ( + "fmt" + "strings" +) + +// RetagRequest gives the source image and target image of retag +type RetagRequest struct { + Tag string `json:"tag"` // The new tag + SrcImage string `json:"src_image"` // Source images in format /: + Override bool `json:"override"` // If target tag exists, whether override it +} + +// Image holds each part (project, repo, tag) of an image name +type Image struct { + Project string + Repo string + Tag string +} + +// ParseImage parses an image name such as 'library/app:v1.0' to a structure with +// project, repo, and tag fields +func ParseImage(image string) (*Image, error) { + repo := strings.SplitN(image, "/", 2) + if len(repo) < 2 { + return nil, fmt.Errorf("Unable to parse image from string: %s", image) + } + i := strings.SplitN(repo[1], ":", 2) + res := &Image{ + Project: repo[0], + Repo: i[0], + } + if len(i) == 2 { + res.Tag = i[1] + } + return res, nil +} diff --git a/src/common/models/retag_test.go b/src/common/models/retag_test.go new file mode 100644 index 000000000..cf80a80d0 --- /dev/null +++ b/src/common/models/retag_test.go @@ -0,0 +1,75 @@ +// 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 models + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseImage(t *testing.T) { + cases := []struct { + Input string + Expected *Image + Valid bool + }{ + { + Input: "library/busybox", + Expected: &Image{ + Project: "library", + Repo: "busybox", + Tag: "", + }, + Valid: true, + }, + { + Input: "library/busybox:v1.0", + Expected: &Image{ + Project: "library", + Repo: "busybox", + Tag: "v1.0", + }, + Valid: true, + }, + { + Input: "library/busybox:sha256:9e2c9d5f44efbb6ee83aecd17a120c513047d289d142ec5738c9f02f9b24ad07", + Expected: &Image{ + Project: "library", + Repo: "busybox", + Tag: "sha256:9e2c9d5f44efbb6ee83aecd17a120c513047d289d142ec5738c9f02f9b24ad07", + }, + Valid: true, + }, + { + Input: "busybox/v1.0", + Valid: false, + }, + } + + for _, c := range cases { + output, err := ParseImage(c.Input) + if c.Valid { + if !reflect.DeepEqual(output, c.Expected) { + assert.Equal(t, c.Expected, output) + } + } else { + if err != nil { + t.Errorf("failed to parse image %s", c.Input) + } + } + } +} diff --git a/src/common/utils/registry/auth/tokenauthorizer.go b/src/common/utils/registry/auth/tokenauthorizer.go index 7f2eb0506..ccb4cca49 100644 --- a/src/common/utils/registry/auth/tokenauthorizer.go +++ b/src/common/utils/registry/auth/tokenauthorizer.go @@ -38,6 +38,17 @@ type tokenGenerator interface { generate(scopes []*token.ResourceActions, endpoint string) (*models.Token, error) } +// UserAgentModifier adds the "User-Agent" header to the request +type UserAgentModifier struct { + UserAgent string +} + +// Modify adds user-agent header to the request +func (u *UserAgentModifier) Modify(req *http.Request) error { + req.Header.Set(http.CanonicalHeaderKey("User-Agent"), u.UserAgent) + return nil +} + // tokenAuthorizer implements registry.Modifier interface. It parses scopses // from the request, generates authentication token and modifies the requset // by adding the token @@ -246,7 +257,7 @@ func ping(client *http.Client, endpoint string) (string, string, error) { } } - log.Warningf("schemes %v are unsupportted", challenges) + log.Warningf("Schemas %v are unsupported", challenges) return "", "", nil } diff --git a/src/common/utils/registry/auth/tokenauthorizer_test.go b/src/common/utils/registry/auth/tokenauthorizer_test.go index fc7d2ec90..b2343f0ff 100644 --- a/src/common/utils/registry/auth/tokenauthorizer_test.go +++ b/src/common/utils/registry/auth/tokenauthorizer_test.go @@ -206,3 +206,17 @@ func TestModifyOfStandardTokenAuthorizer(t *testing.T) { tk := req.Header.Get("Authorization") assert.Equal(t, strings.ToLower("Bearer "+token.Token), strings.ToLower(tk)) } + +func TestUserAgentModifier(t *testing.T) { + agent := "harbor-registry-client" + modifier := &UserAgentModifier{ + UserAgent: agent, + } + req, err := http.NewRequest(http.MethodGet, "http://registry/v2/", nil) + require.Nil(t, err) + modifier.Modify(req) + actual := req.Header.Get("User-Agent") + if actual != agent { + t.Errorf("expect request to have header User-Agent=%s, but got User-Agent=%s", agent, actual) + } +} diff --git a/src/common/utils/registry/repository.go b/src/common/utils/registry/repository.go index 126d198ee..7e7a8a27f 100644 --- a/src/common/utils/registry/repository.go +++ b/src/common/utils/registry/repository.go @@ -258,6 +258,28 @@ func (r *Repository) DeleteManifest(digest string) error { } } +// MountBlob ... +func (r *Repository) MountBlob(digest, from string) error { + req, err := http.NewRequest("POST", buildMountBlobURL(r.Endpoint.String(), r.Name, digest, from), nil) + req.Header.Set(http.CanonicalHeaderKey("Content-Length"), "0") + + resp, err := r.client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("status %d, body: %s", resp.StatusCode, string(b)) + } + + return nil +} + // DeleteTag ... func (r *Repository) DeleteTag(tag string) error { digest, exist, err := r.ManifestExist(tag) @@ -462,6 +484,10 @@ func buildBlobURL(endpoint, repoName, reference string) string { return fmt.Sprintf("%s/v2/%s/blobs/%s", endpoint, repoName, reference) } +func buildMountBlobURL(endpoint, repoName, digest, from string) string { + return fmt.Sprintf("%s/v2/%s/blobs/uploads/?mount=%s&from=%s", endpoint, repoName, digest, from) +} + func buildInitiateBlobUploadURL(endpoint, repoName string) string { return fmt.Sprintf("%s/v2/%s/blobs/uploads/", endpoint, repoName) } diff --git a/src/common/utils/registry/repository_test.go b/src/common/utils/registry/repository_test.go index 8293920e6..86efa96cd 100644 --- a/src/common/utils/registry/repository_test.go +++ b/src/common/utils/registry/repository_test.go @@ -422,3 +422,37 @@ func TestBuildMonolithicBlobUploadURL(t *testing.T) { require.Nil(t, err) assert.Equal(t, expected, url) } + +func TestBuildMountBlobURL(t *testing.T) { + endpoint := "http://192.169.0.1" + repoName := "library/hello-world" + digest := "sha256:ef15416724f6e2d5d5b422dc5105add931c1f2a45959cd4993e75e47957b3b55" + from := "library/hi-world" + expected := fmt.Sprintf("%s/v2/%s/blobs/uploads/?mount=%s&from=%s", endpoint, repoName, digest, from) + + actual := buildMountBlobURL(endpoint, repoName, digest, from) + assert.Equal(t, expected, actual) +} + +func TestMountBlob(t *testing.T) { + mountHandler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + } + + server := test.NewServer( + &test.RequestHandlerMapping{ + Method: "POST", + Pattern: fmt.Sprintf("/v2/%s/blobs/uploads/", repository), + Handler: mountHandler, + }) + defer server.Close() + + client, err := newRepository(server.URL) + if err != nil { + t.Fatalf("failed to create client for repository: %v", err) + } + + if err = client.MountBlob(digest, "library/hi-world"); err != nil { + t.Fatalf("failed to mount blob: %v", err) + } +} diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 71bd87a58..e123990cc 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -140,7 +140,7 @@ func init() { beego.Router("/api/repositories/*/tags/:tag/labels", &RepositoryLabelAPI{}, "get:GetOfImage;post:AddToImage") beego.Router("/api/repositories/*/tags/:tag/labels/:id([0-9]+", &RepositoryLabelAPI{}, "delete:RemoveFromImage") beego.Router("/api/repositories/*/tags/:tag", &RepositoryAPI{}, "delete:Delete;get:GetTag") - beego.Router("/api/repositories/*/tags", &RepositoryAPI{}, "get:GetTags") + beego.Router("/api/repositories/*/tags", &RepositoryAPI{}, "get:GetTags;post:Retag") beego.Router("/api/repositories/*/tags/:tag/manifest", &RepositoryAPI{}, "get:GetManifests") beego.Router("/api/repositories/*/signatures", &RepositoryAPI{}, "get:GetSignatures") beego.Router("/api/repositories/top", &RepositoryAPI{}, "get:GetTopRepos") @@ -618,6 +618,19 @@ func (a testapi) GetReposTags(authInfo usrInfo, repoName string) (int, interface return http.StatusOK, result, nil } +// RetagImage retag image to another tag +func (a testapi) RetagImage(authInfo usrInfo, repoName string, retag *apilib.Retag) (int, error) { + _sling := sling.New().Post(a.basePath) + + path := fmt.Sprintf("/api/repositories/%s/tags", repoName) + + _sling = _sling.Path(path) + _sling = _sling.BodyJSON(retag) + + httpStatusCode, _, err := request(_sling, jsonAcceptHeader, authInfo) + return httpStatusCode, err +} + // Get manifests of a relevant repository func (a testapi) GetReposManifests(authInfo usrInfo, repoName string, tag string) (int, error) { _sling := sling.New().Get(a.basePath) diff --git a/src/core/api/repository.go b/src/core/api/repository.go index 6f1b46d3b..0802640be 100644 --- a/src/core/api/repository.go +++ b/src/core/api/repository.go @@ -424,6 +424,82 @@ func (ra *RepositoryAPI) GetTag() { ra.ServeJSON() } +// Retag tags an existing image to another tag in this repo, the source image is specified by request body. +func (ra *RepositoryAPI) Retag() { + if !ra.SecurityCtx.IsAuthenticated() { + ra.HandleUnauthorized() + return + } + + repoName := ra.GetString(":splat") + request := models.RetagRequest{} + ra.DecodeJSONReq(&request) + srcImage, err := models.ParseImage(request.SrcImage) + if err != nil { + ra.HandleBadRequest(fmt.Sprintf("invalid src image string '%s', should in format '/:'", request.SrcImage)) + return + } + + // Check whether source image exists + exist, _, err := ra.checkExistence(fmt.Sprintf("%s/%s", srcImage.Project, srcImage.Repo), srcImage.Tag) + if err != nil { + ra.HandleInternalServerError(fmt.Sprintf("check existence of %s error: %v", request.SrcImage, err)) + return + } + if !exist { + ra.HandleNotFound(fmt.Sprintf("image %s not exist", request.SrcImage)) + return + } + + // Check whether target project exists + project, repo := utils.ParseRepository(repoName) + exist, err = ra.ProjectMgr.Exists(project) + if err != nil { + ra.ParseAndHandleError(fmt.Sprintf("failed to check the existence of project %s", project), err) + return + } + if !exist { + ra.HandleNotFound(fmt.Sprintf("project %s not found", project)) + return + } + + // If override not allowed, check whether target tag already exists + if !request.Override { + exist, _, err := ra.checkExistence(repoName, request.Tag) + if err != nil { + ra.HandleInternalServerError(fmt.Sprintf("check existence of %s:%s error: %v", repoName, request.Tag, err)) + return + } + if exist { + ra.HandleConflict(fmt.Sprintf("tag '%s' already existed for '%s'", request.Tag, repoName)) + return + } + } + + // Check whether use has read permission to source project + if !ra.SecurityCtx.HasReadPerm(srcImage.Project) { + log.Errorf("user has no read permission to project '%s'", srcImage.Project) + ra.HandleUnauthorized() + return + } + + // Check whether user has write permission to target project + if !ra.SecurityCtx.HasWritePerm(project) { + log.Errorf("user has no write permission to project '%s'", project) + ra.HandleUnauthorized() + return + } + + // Retag the image + if err = coreutils.Retag(srcImage, &models.Image{ + Project: project, + Repo: repo, + Tag: request.Tag, + }); err != nil { + ra.HandleInternalServerError(fmt.Sprintf("%v", err)) + } +} + // GetTags returns tags of a repository func (ra *RepositoryAPI) GetTags() { repoName := ra.GetString(":splat") diff --git a/src/core/api/repository_test.go b/src/core/api/repository_test.go index 592d9b4a5..bf61799e8 100644 --- a/src/core/api/repository_test.go +++ b/src/core/api/repository_test.go @@ -21,6 +21,7 @@ import ( "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/dao/project" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/tests/apitests/apilib" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -348,3 +349,96 @@ func TestPutOfRepository(t *testing.T) { require.NotNil(t, repository) assert.Equal(t, desc.Description, repository.Description) } + +func TestRetag(t *testing.T) { + assert := assert.New(t) + apiTest := newHarborAPI() + repo := "library/hello-world" + + fmt.Println("Testing Image Retag API") + // -------------------case 1 : response code = 200------------------------// + fmt.Println("case 1 : response code = 200") + retagReq := &apilib.Retag{ + Tag: "prd", + SrcImage: "library/hello-world:latest", + Override: true, + } + code, err := apiTest.RetagImage(*admin, repo, retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(200), code, "response code should be 200") + } + + // -------------------case 2 : response code = 400------------------------// + fmt.Println("case 2 : response code = 400: invalid image value provided") + retagReq = &apilib.Retag{ + Tag: "prd", + SrcImage: "hello-world:latest", + Override: true, + } + httpStatusCode, err := apiTest.RetagImage(*admin, repo, retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(400), httpStatusCode, "httpStatusCode should be 400") + } + + // -------------------case 3 : response code = 404------------------------// + fmt.Println("case 3 : response code = 404: source image not exist") + retagReq = &apilib.Retag{ + Tag: "prd", + SrcImage: "release/hello-world:notexist", + Override: true, + } + httpStatusCode, err = apiTest.RetagImage(*admin, repo, retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(404), httpStatusCode, "httpStatusCode should be 404") + } + + // -------------------case 4 : response code = 404------------------------// + fmt.Println("case 4 : response code = 404: target project not exist") + retagReq = &apilib.Retag{ + Tag: "prd", + SrcImage: "library/hello-world:latest", + Override: true, + } + httpStatusCode, err = apiTest.RetagImage(*admin, "nonexist/hello-world", retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(404), httpStatusCode, "httpStatusCode should be 404") + } + + // -------------------case 5 : response code = 401------------------------// + fmt.Println("case 5 : response code = 401, unathorized") + retagReq = &apilib.Retag{ + Tag: "prd", + SrcImage: "library/hello-world:latest", + Override: true, + } + httpStatusCode, err = apiTest.RetagImage(*unknownUsr, repo, retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(401), httpStatusCode, "httpStatusCode should be 401") + } + + // -------------------case 6 : response code = 409------------------------// + fmt.Println("case 6 : response code = 409, conflict") + retagReq = &apilib.Retag{ + Tag: "latest", + SrcImage: "library/hello-world:latest", + Override: false, + } + httpStatusCode, err = apiTest.RetagImage(*admin, repo, retagReq) + if err != nil { + t.Errorf("failed to retag: %v", err) + } else { + assert.Equal(int(409), httpStatusCode, "httpStatusCode should be 409") + } + + fmt.Printf("\n") +} diff --git a/src/core/router.go b/src/core/router.go index f1b259911..035691019 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -72,7 +72,7 @@ func initRouters() { beego.Router("/api/repositories/*/tags/:tag", &api.RepositoryAPI{}, "delete:Delete;get:GetTag") beego.Router("/api/repositories/*/tags/:tag/labels", &api.RepositoryLabelAPI{}, "get:GetOfImage;post:AddToImage") beego.Router("/api/repositories/*/tags/:tag/labels/:id([0-9]+)", &api.RepositoryLabelAPI{}, "delete:RemoveFromImage") - beego.Router("/api/repositories/*/tags", &api.RepositoryAPI{}, "get:GetTags") + beego.Router("/api/repositories/*/tags", &api.RepositoryAPI{}, "get:GetTags;post:Retag") beego.Router("/api/repositories/*/tags/:tag/scan", &api.RepositoryAPI{}, "post:ScanImage") beego.Router("/api/repositories/*/tags/:tag/vulnerability/details", &api.RepositoryAPI{}, "Get:VulnerabilityDetails") beego.Router("/api/repositories/*/tags/:tag/manifest", &api.RepositoryAPI{}, "get:GetManifests") diff --git a/src/core/utils/retag.go b/src/core/utils/retag.go new file mode 100644 index 000000000..b53f5b713 --- /dev/null +++ b/src/core/utils/retag.go @@ -0,0 +1,95 @@ +// 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 utils + +import ( + "fmt" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/common/utils/registry" + + "github.com/docker/distribution/manifest/schema1" + "github.com/docker/distribution/manifest/schema2" +) + +// Retag tags an image to another +func Retag(srcImage, destImage *models.Image) error { + isSameRepo := getRepoName(srcImage) == getRepoName(destImage) + srcClient, err := NewRepositoryClientForUI("harbor-ui", getRepoName(srcImage)) + if err != nil { + return err + } + destClient := srcClient + if !isSameRepo { + destClient, err = NewRepositoryClientForUI("harbor-ui", getRepoName(destImage)) + if err != nil { + return err + } + } + + _, exist, err := srcClient.ManifestExist(srcImage.Tag) + if err != nil { + log.Errorf("check existence of manifest '%s:%s' error: %v", srcClient.Name, srcImage.Tag, err) + return err + } + + if !exist { + log.Errorf("source image %s:%s not found", srcClient.Name, srcImage.Tag) + return fmt.Errorf("image %s:%s not found", srcClient.Name, srcImage.Tag) + } + + accepted := []string{schema1.MediaTypeManifest, schema2.MediaTypeManifest} + digest, mediaType, payload, err := srcClient.PullManifest(srcImage.Tag, accepted) + if err != nil { + return err + } + + manifest, _, err := registry.UnMarshal(mediaType, payload) + if err != nil { + return err + } + + destDigest, exist, err := destClient.ManifestExist(destImage.Tag) + if err != nil { + log.Errorf("check existence of manifest '%s:%s' error: %v", destClient.Name, destImage.Tag, err) + return err + } + if exist && destDigest == digest { + log.Infof("manifest of '%s:%s' already exist", destClient.Name, destImage.Tag) + return nil + } + + if !isSameRepo { + for _, descriptor := range manifest.References() { + err := destClient.MountBlob(descriptor.Digest.String(), srcClient.Name) + if err != nil { + log.Errorf("mount blob '%s' error: %v", descriptor.Digest.String(), err) + return err + } + } + } + + if _, err = destClient.PushManifest(destImage.Tag, mediaType, payload); err != nil { + log.Errorf("push manifest '%s:%s' error: %v", destClient.Name, destImage.Tag, err) + return err + } + + return nil +} + +func getRepoName(image *models.Image) string { + return fmt.Sprintf("%s/%s", image.Project, image.Repo) +} diff --git a/src/core/utils/utils.go b/src/core/utils/utils.go index 48eb030c6..5a07cf3b4 100644 --- a/src/core/utils/utils.go +++ b/src/core/utils/utils.go @@ -16,12 +16,12 @@ package utils import ( + "net/http" + "github.com/goharbor/harbor/src/common/utils/registry" "github.com/goharbor/harbor/src/common/utils/registry/auth" "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/core/service/token" - - "net/http" ) // NewRepositoryClientForUI creates a repository client that can only be used to @@ -32,8 +32,11 @@ func NewRepositoryClientForUI(username, repository string) (*registry.Repository return nil, err } + uam := &auth.UserAgentModifier{ + UserAgent: "harbor-registry-client", + } authorizer := auth.NewRawTokenAuthorizer(username, token.Registry) - transport := registry.NewTransport(http.DefaultTransport, authorizer) + transport := registry.NewTransport(http.DefaultTransport, authorizer, uam) client := &http.Client{ Transport: transport, } diff --git a/tests/apitests/apilib/repository.go b/tests/apitests/apilib/repository.go index 8d4b9e756..d3943e118 100644 --- a/tests/apitests/apilib/repository.go +++ b/tests/apitests/apilib/repository.go @@ -48,3 +48,16 @@ type Repository struct { // OS of the image. Os string `json:"os,omitempty"` } + +// Retag describes a retag request +type Retag struct { + + // The new tag + Tag string `json:"tag"` + + // Source images in format /: + SrcImage string `json:"src_image"` + + // If target tag exists, whether override it + Override bool `json:"override"` +}