From 0e0c42d3c086d9bdefc0081d89854fc383ea9ef9 Mon Sep 17 00:00:00 2001 From: chlins Date: Tue, 8 Oct 2019 09:54:28 +0800 Subject: [PATCH] feat(replication): support for jfrog artifactory docker image replication Signed-off-by: chlins --- .../job/impl/replication/replication.go | 2 + src/replication/adapter/jfrog/adapter.go | 310 ++++++++++++++++++ src/replication/adapter/jfrog/adapter_test.go | 132 ++++++++ src/replication/adapter/jfrog/types.go | 24 ++ src/replication/model/registry.go | 17 +- src/replication/replication.go | 2 + 6 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 src/replication/adapter/jfrog/adapter.go create mode 100644 src/replication/adapter/jfrog/adapter_test.go create mode 100644 src/replication/adapter/jfrog/types.go diff --git a/src/jobservice/job/impl/replication/replication.go b/src/jobservice/job/impl/replication/replication.go index 849c5c0a2..d7a35cdbf 100644 --- a/src/jobservice/job/impl/replication/replication.go +++ b/src/jobservice/job/impl/replication/replication.go @@ -42,6 +42,8 @@ import ( _ "github.com/goharbor/harbor/src/replication/adapter/azurecr" // register the AliACR adapter _ "github.com/goharbor/harbor/src/replication/adapter/aliacr" + // register the Jfrog Artifactory adapter + _ "github.com/goharbor/harbor/src/replication/adapter/jfrog" // register the Helm Hub adapter _ "github.com/goharbor/harbor/src/replication/adapter/helmhub" ) diff --git a/src/replication/adapter/jfrog/adapter.go b/src/replication/adapter/jfrog/adapter.go new file mode 100644 index 000000000..f77d81360 --- /dev/null +++ b/src/replication/adapter/jfrog/adapter.go @@ -0,0 +1,310 @@ +package jfrog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strconv" + "strings" + + common_http "github.com/goharbor/harbor/src/common/http" + "github.com/goharbor/harbor/src/common/http/modifier" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/common/utils/registry/auth" + adp "github.com/goharbor/harbor/src/replication/adapter" + "github.com/goharbor/harbor/src/replication/adapter/native" + "github.com/goharbor/harbor/src/replication/model" + "github.com/goharbor/harbor/src/replication/util" +) + +func init() { + err := adp.RegisterFactory(model.RegistryTypeJfrogArtifactory, AdapterFactory) + if err != nil { + log.Errorf("failed to register factory for jfrog artifactory: %v", err) + return + } + log.Infof("the factory of jfrog artifactory adapter was registered") +} + +// Adapter is for images replications between harbor and jfrog artifactory image repository +type adapter struct { + *native.Adapter + registry *model.Registry + client *common_http.Client +} + +var _ adp.Adapter = (*adapter)(nil) + +// Info gets info about jfrog artifactory adapter +func (a *adapter) Info() (info *model.RegistryInfo, err error) { + info = &model.RegistryInfo{ + Type: model.RegistryTypeJfrogArtifactory, + SupportedResourceTypes: []model.ResourceType{ + model.ResourceTypeImage, + }, + SupportedResourceFilters: []*model.FilterStyle{ + { + Type: model.FilterTypeName, + Style: model.FilterStyleTypeText, + }, + { + Type: model.FilterTypeTag, + Style: model.FilterStyleTypeText, + }, + }, + SupportedTriggers: []model.TriggerType{ + model.TriggerTypeManual, + model.TriggerTypeScheduled, + }, + } + return +} + +// AdapterFactory is the factory for jfrog artifactory adapter +func AdapterFactory(registry *model.Registry) (adp.Adapter, error) { + dockerRegistryAdapter, err := native.NewAdapter(registry) + if err != nil { + return nil, err + } + + var ( + modifiers = []modifier.Modifier{ + &auth.UserAgentModifier{ + UserAgent: adp.UserAgentReplication, + }} + ) + if registry.Credential != nil { + modifiers = append(modifiers, auth.NewBasicAuthCredential( + registry.Credential.AccessKey, + registry.Credential.AccessSecret)) + } + + return &adapter{ + Adapter: dockerRegistryAdapter, + registry: registry, + client: common_http.NewClient( + &http.Client{ + Transport: util.GetHTTPTransport(registry.Insecure), + }, + modifiers..., + ), + }, nil + +} + +// PrepareForPush creates local docker repository in jfrog artifactory +func (a *adapter) PrepareForPush(resources []*model.Resource) error { + var namespaces []string + for _, resource := range resources { + if resource == nil { + return errors.New("the resource cannot be null") + } + if resource.Metadata == nil { + return errors.New("the metadata of resource cannot be null") + } + if resource.Metadata.Repository == nil { + return errors.New("the namespace of resource cannot be null") + } + if len(resource.Metadata.Repository.Name) == 0 { + return errors.New("the name of namespace cannot be null") + } + path := strings.Split(resource.Metadata.Repository.Name, "/") + if len(path) > 0 { + namespaces = append(namespaces, path[0]) + } + } + + repositories, err := a.getLocalRepositories() + if err != nil { + log.Errorf("Get local repositories error: %v", err) + return err + } + + existedRepositories := make(map[string]struct{}) + for _, repo := range repositories { + existedRepositories[repo.Key] = struct{}{} + } + + for _, namespace := range namespaces { + if _, ok := existedRepositories[namespace]; ok { + log.Debugf("Namespace %s already existed in remote, skip create it", namespace) + } else { + err = a.createNamespace(namespace) + if err != nil { + log.Errorf("Create Namespace %s error: %v", namespace, err) + return err + } + } + } + + return nil +} + +func (a *adapter) getLocalRepositories() ([]*repository, error) { + var repositories []*repository + url := fmt.Sprintf("%s/artifactory/api/repositories?type=local&packageType=docker", a.registry.URL) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return repositories, err + } + + resp, err := a.client.Do(req) + if err != nil { + return repositories, err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return repositories, err + } + + err = json.Unmarshal(body, &repositories) + return repositories, err +} + +// create repository with docker local type +// this operation needs admin +func (a *adapter) createNamespace(namespace string) error { + ns := newDefaultDockerLocalRepository(namespace) + body, err := json.Marshal(ns) + if err != nil { + return err + } + + url := fmt.Sprintf("%s/artifactory/api/repositories/%s", a.registry.URL, namespace) + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + return &common_http.Error{ + Code: resp.StatusCode, + Message: string(b), + } +} + +// PushBlob can not use naive PushBlob due to MonolithicUpload, Jfrog now just support push by chunk +// related issue: https://www.jfrog.com/jira/browse/RTFACT-19344 +func (a *adapter) PushBlob(repository, digest string, size int64, blob io.Reader) error { + location, err := a.preparePushBlob(repository) + if err != nil { + return err + } + + url := fmt.Sprintf("%s/v2/%s/blobs/uploads/%s", a.registry.URL, repository, location) + req, err := http.NewRequest(http.MethodPatch, url, blob) + if err != nil { + return err + } + rangeSize := strconv.Itoa(int(size)) + req.Header.Set("Content-Length", rangeSize) + req.Header.Set("Content-Range", fmt.Sprintf("0-%s", rangeSize)) + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := a.client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusAccepted { + return a.ackPushBlob(repository, digest, location, rangeSize) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + return &common_http.Error{ + Code: resp.StatusCode, + Message: string(b), + } +} + +func (a *adapter) preparePushBlob(repository string) (string, error) { + url := fmt.Sprintf("%s/v2/%s/blobs/uploads/", a.registry.URL, repository) + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return "", err + } + + req.Header.Set(http.CanonicalHeaderKey("Content-Length"), "0") + resp, err := a.client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusAccepted { + return resp.Header.Get(http.CanonicalHeaderKey("Docker-Upload-Uuid")), nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + err = &common_http.Error{ + Code: resp.StatusCode, + Message: string(b), + } + + return "", err +} + +func (a *adapter) ackPushBlob(repository, digest, location, size string) error { + url := fmt.Sprintf("%s/v2/%s/blobs/uploads/%s?digest=%s", a.registry.URL, repository, location, digest) + req, err := http.NewRequest(http.MethodPut, url, nil) + if err != nil { + return err + } + + resp, err := a.client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + return nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + err = &common_http.Error{ + Code: resp.StatusCode, + Message: string(b), + } + + return err +} diff --git a/src/replication/adapter/jfrog/adapter_test.go b/src/replication/adapter/jfrog/adapter_test.go new file mode 100644 index 000000000..1f62a62f4 --- /dev/null +++ b/src/replication/adapter/jfrog/adapter_test.go @@ -0,0 +1,132 @@ +package jfrog + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/goharbor/harbor/src/common/utils/test" + adp "github.com/goharbor/harbor/src/replication/adapter" + "github.com/goharbor/harbor/src/replication/model" + "github.com/stretchr/testify/assert" +) + +const ( + fakeUploadID = "ac5fbe00-15f7-4d36-aa0e-cbdcdb15ec75" + fakeDigest = "sha256:f0f53b24e58a432aaa333d9993240340" + + fakeNamespace = "mydocker" + fakeRepository = "mydocker/nginx" +) + +func getMockAdapter(t *testing.T, hasCred, health bool) (*adapter, *httptest.Server) { + server := test.NewServer( + &test.RequestHandlerMapping{ + Method: http.MethodGet, + Pattern: "/artifactory/api/repositories", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[ + { + "key": "cyzhang", + "description": "", + "type": "LOCAL", + "url": "http://49.4.2.82:8081/artifactory/cyzhang", + "packageType": "Docker" + }, + { + "key": "mydocker", + "type": "LOCAL", + "url": "http://49.4.2.82:8081/artifactory/mydocker", + "packageType": "Docker" + } +]`)) + }, + }, + &test.RequestHandlerMapping{ + Method: http.MethodPut, + Pattern: fmt.Sprintf("/artifactory/api/repositories/%s", fakeNamespace), + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }, + &test.RequestHandlerMapping{ + Method: http.MethodPost, + Pattern: fmt.Sprintf("/v2/%s/blobs/uploads/", fakeRepository), + Handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Docker-Upload-Uuid", fakeUploadID) + w.WriteHeader(http.StatusAccepted) + }, + }, + &test.RequestHandlerMapping{ + Method: http.MethodPatch, + Pattern: fmt.Sprintf("/v2/%s/blobs/uploads/%s", fakeRepository, fakeUploadID), + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + }, + &test.RequestHandlerMapping{ + Method: http.MethodPut, + Pattern: fmt.Sprintf("/v2/%s/blobs/uploads/%s", fakeRepository, fakeUploadID), + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + }, + ) + + registry := &model.Registry{ + Type: model.RegistryTypeJfrogArtifactory, + URL: server.URL, + } + + if hasCred { + registry.Credential = &model.Credential{ + AccessKey: "admin", + AccessSecret: "password", + } + } + + factory, err := adp.GetFactory(model.RegistryTypeJfrogArtifactory) + assert.Nil(t, err) + assert.NotNil(t, factory) + a, err := factory(registry) + + assert.Nil(t, err) + return a.(*adapter), server +} + +func TestAdapter_Info(t *testing.T) { + a, s := getMockAdapter(t, true, true) + defer s.Close() + info, err := a.Info() + assert.Nil(t, err) + assert.NotNil(t, info) + assert.EqualValues(t, 1, len(info.SupportedResourceTypes)) + assert.EqualValues(t, model.ResourceTypeImage, info.SupportedResourceTypes[0]) +} + +func TestAdapter_PrepareForPush(t *testing.T) { + a, s := getMockAdapter(t, true, true) + defer s.Close() + resources := []*model.Resource{ + { + Type: model.ResourceTypeImage, + Metadata: &model.ResourceMetadata{ + Repository: &model.Repository{ + Name: "mydocker/busybox", + }, + }, + }, + } + err := a.PrepareForPush(resources) + assert.Nil(t, err) +} + +func TestAdapter_PushBlob(t *testing.T) { + a, s := getMockAdapter(t, true, true) + defer s.Close() + err := a.PushBlob(fakeRepository, fakeDigest, 20, bytes.NewReader([]byte("test"))) + assert.Nil(t, err) +} diff --git a/src/replication/adapter/jfrog/types.go b/src/replication/adapter/jfrog/types.go new file mode 100644 index 000000000..b222ad082 --- /dev/null +++ b/src/replication/adapter/jfrog/types.go @@ -0,0 +1,24 @@ +package jfrog + +type repository struct { + Key string `json:"key"` + Type string `json:"type"` + URL string `json:"url"` + PackageType string `json:"packageType"` +} + +type repositoryCreate struct { + Key string `json:"key"` + Rclass string `json:"rclass"` + PackageType string `json:"packageType"` + RepoLayoutRef string `json:"repoLayoutRef"` +} + +func newDefaultDockerLocalRepository(key string) *repositoryCreate { + return &repositoryCreate{ + Key: key, + Rclass: "local", + PackageType: "docker", + RepoLayoutRef: "simple-default", + } +} diff --git a/src/replication/model/registry.go b/src/replication/model/registry.go index dfb743cce..a31f50896 100644 --- a/src/replication/model/registry.go +++ b/src/replication/model/registry.go @@ -22,14 +22,15 @@ import ( // const definition const ( - RegistryTypeHarbor RegistryType = "harbor" - RegistryTypeDockerHub RegistryType = "docker-hub" - RegistryTypeDockerRegistry RegistryType = "docker-registry" - RegistryTypeHuawei RegistryType = "huawei-SWR" - RegistryTypeGoogleGcr RegistryType = "google-gcr" - RegistryTypeAwsEcr RegistryType = "aws-ecr" - RegistryTypeAzureAcr RegistryType = "azure-acr" - RegistryTypeAliAcr RegistryType = "ali-acr" + RegistryTypeHarbor RegistryType = "harbor" + RegistryTypeDockerHub RegistryType = "docker-hub" + RegistryTypeDockerRegistry RegistryType = "docker-registry" + RegistryTypeHuawei RegistryType = "huawei-SWR" + RegistryTypeGoogleGcr RegistryType = "google-gcr" + RegistryTypeAwsEcr RegistryType = "aws-ecr" + RegistryTypeAzureAcr RegistryType = "azure-acr" + RegistryTypeAliAcr RegistryType = "ali-acr" + RegistryTypeJfrogArtifactory RegistryType = "jfrog-artifactory" RegistryTypeHelmHub RegistryType = "helm-hub" diff --git a/src/replication/replication.go b/src/replication/replication.go index 46b0feb3c..b06404874 100644 --- a/src/replication/replication.go +++ b/src/replication/replication.go @@ -43,6 +43,8 @@ import ( _ "github.com/goharbor/harbor/src/replication/adapter/azurecr" // register the AliACR adapter _ "github.com/goharbor/harbor/src/replication/adapter/aliacr" + // register the Jfrog Artifactory adapter + _ "github.com/goharbor/harbor/src/replication/adapter/jfrog" // register the Helm Hub adapter _ "github.com/goharbor/harbor/src/replication/adapter/helmhub" )