diff --git a/src/pkg/p2p/preheat/models/provider/instance.go b/src/pkg/p2p/preheat/models/provider/instance.go new file mode 100644 index 000000000..d21315d0c --- /dev/null +++ b/src/pkg/p2p/preheat/models/provider/instance.go @@ -0,0 +1,48 @@ +// 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 provider + +const ( + // PreheatingImageTypeImage defines the 'image' type of preheating images + PreheatingImageTypeImage = "image" + // PreheatingStatusPending means the preheating is waiting for starting + PreheatingStatusPending = "PENDING" + // PreheatingStatusRunning means the preheating is ongoing + PreheatingStatusRunning = "RUNNING" + // PreheatingStatusSuccess means the preheating is success + PreheatingStatusSuccess = "SUCCESS" + // PreheatingStatusFail means the preheating is failed + PreheatingStatusFail = "FAIL" +) + +// Instance defines the properties of the preheating provider instance. +type Instance struct { + ID int64 `orm:"pk;auto;column(id)" json:"id"` + Name string `orm:"column(name)" json:"name"` + Description string `orm:"column(description)" json:"description"` + Vendor string `orm:"column(vendor)" json:"vendor"` + Endpoint string `orm:"column(endpoint)" json:"endpoint"` + AuthMode string `orm:"column(auth_mode)" json:"auth_mode"` + // The auth credential data if exists + AuthInfo map[string]string `orm:"column(-)" json:"auth_info,omitempty"` + // Data format for "AuthInfo" + AuthData string `orm:"column(auth_data)" json:"-"` + // Default 'Unknown', use separate API for client to retrieve + Status string `orm:"column(-)" json:"status"` + Enabled bool `orm:"column(enabled)" json:"enabled"` + Default bool `orm:"column(is_default)" json:"default"` + Insecure bool `orm:"column(insecure)" json:"insecure"` + SetupTimestamp int64 `orm:"column(setup_timestamp)" json:"setup_timestamp"` +} diff --git a/src/pkg/p2p/preheat/provider/auth/basic_handler.go b/src/pkg/p2p/preheat/provider/auth/basic_handler.go new file mode 100644 index 000000000..b1e518a24 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/basic_handler.go @@ -0,0 +1,33 @@ +package auth + +import ( + "errors" + "net/http" + "reflect" +) + +// BasicAuthHandler handle the basic auth mode. +type BasicAuthHandler struct { + *BaseHandler +} + +// Mode implements @Handler.Mode +func (b *BasicAuthHandler) Mode() string { + return AuthModeBasic +} + +// Authorize implements @Handler.Authorize +func (b *BasicAuthHandler) Authorize(req *http.Request, cred *Credential) error { + if err := b.BaseHandler.Authorize(req, cred); err != nil { + return err + } + + if len(cred.Data) == 0 { + return errors.New("missing username and/or password") + } + + key := reflect.ValueOf(cred.Data).MapKeys()[0].String() + req.SetBasicAuth(key, cred.Data[key]) + + return nil +} diff --git a/src/pkg/p2p/preheat/provider/auth/cred.go b/src/pkg/p2p/preheat/provider/auth/cred.go new file mode 100644 index 000000000..b258bb785 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/cred.go @@ -0,0 +1,23 @@ +package auth + +const ( + // AuthModeNone means no auth required + AuthModeNone = "NONE" + // AuthModeBasic is basic mode + AuthModeBasic = "BASIC" + // AuthModeOAuth is OAuth mode + AuthModeOAuth = "OAUTH" + // AuthModeCustom is custom mode + AuthModeCustom = "CUSTOM" +) + +// Credential stores the related data for authorization. +type Credential struct { + Mode string + + // Keep the auth data. + // If authMode is 'BASIC', then 'username' and 'password' are stored; + // If authMode is 'OAUTH', then 'token' is stored' + // If authMode is 'CUSTOM', then 'header_key' with corresponding header value are stored. + Data map[string]string +} diff --git a/src/pkg/p2p/preheat/provider/auth/custom_handler.go b/src/pkg/p2p/preheat/provider/auth/custom_handler.go new file mode 100644 index 000000000..c87375040 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/custom_handler.go @@ -0,0 +1,33 @@ +package auth + +import ( + "errors" + "net/http" + "reflect" +) + +// CustomAuthHandler handle the custom auth mode. +type CustomAuthHandler struct { + *BaseHandler +} + +// Mode implements @Handler.Mode +func (c *CustomAuthHandler) Mode() string { + return AuthModeCustom +} + +// Authorize implements @Handler.Authorize +func (c *CustomAuthHandler) Authorize(req *http.Request, cred *Credential) error { + if err := c.BaseHandler.Authorize(req, cred); err != nil { + return err + } + + if len(cred.Data) == 0 { + return errors.New("missing custom token/key data") + } + + key := reflect.ValueOf(cred.Data).MapKeys()[0].String() + req.Header.Set(key, cred.Data[key]) + + return nil +} diff --git a/src/pkg/p2p/preheat/provider/auth/handler.go b/src/pkg/p2p/preheat/provider/auth/handler.go new file mode 100644 index 000000000..de02fc1e1 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/handler.go @@ -0,0 +1,40 @@ +package auth + +import ( + "errors" + "net/http" +) + +// Handler defines how to add authorization data to the requests +// depending on the different auth modes. +type Handler interface { + // Append authorization data to the request depends on cred modes. + // + // If everything is ok, nil error will be returned. + // Otherwise, an error will be got. + Authorize(req *http.Request, cred *Credential) error + + // Mode returns the auth mode identity. + Mode() string +} + +// BaseHandler provides some basic functions like validation. +type BaseHandler struct{} + +// Mode implements @Handler.Mode +func (b *BaseHandler) Mode() string { + return "BASE" +} + +// Authorize implements @Handler.Authorize +func (b *BaseHandler) Authorize(req *http.Request, cred *Credential) error { + if req == nil { + return errors.New("nil request cannot be authorized") + } + + if cred == nil || cred.Data == nil { + return errors.New("no credential data provided") + } + + return nil +} diff --git a/src/pkg/p2p/preheat/provider/auth/handler_test.go b/src/pkg/p2p/preheat/provider/auth/handler_test.go new file mode 100644 index 000000000..1f2132f06 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/handler_test.go @@ -0,0 +1,102 @@ +// 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 auth + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + authorizationHeader = "Authorization" +) + +// AuthHandlerTestSuite is test suite for testing auth handler +type AuthHandlerTestSuite struct { + suite.Suite +} + +// TestAuthHandler is the entry method of running AuthHandlerTestSuite +func TestAuthHandler(t *testing.T) { + suite.Run(t, &AuthHandlerTestSuite{}) +} + +// TestNoneHandler test none handler +func (suite *AuthHandlerTestSuite) TestNoneHandler() { + none := &NoneAuthHandler{} + suite.Equal(AuthModeNone, none.Mode(), "auth mode None") + r, err := http.NewRequest(http.MethodGet, "https://p2p.none.com", nil) + require.NoError(suite.T(), err, "new HTTP request") + err = none.Authorize(r, nil) + require.NoError(suite.T(), err, "authorize HTTP request") + suite.Equal(0, len(r.Header.Get(authorizationHeader)), "check authorization header") +} + +// TestBasicHandler test basic auth handler +func (suite *AuthHandlerTestSuite) TestBasicHandler() { + basic := &BasicAuthHandler{} + suite.Equal(AuthModeBasic, basic.Mode(), "auth mode basic") + r, err := http.NewRequest(http.MethodGet, "https://p2p.basic.com", nil) + require.NoError(suite.T(), err, "new HTTP request") + cred := &Credential{ + Mode: AuthModeBasic, + Data: map[string]string{ + "username": "password", + }, + } + err = basic.Authorize(r, cred) + require.NoError(suite.T(), err, "authorize HTTP request") + encodedStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", "username", "password"))) + suite.Equal(fmt.Sprintf("%s %s", "Basic", encodedStr), r.Header.Get(authorizationHeader), "check basic authorization header") +} + +// TestTokenHandler test token auth handler +func (suite *AuthHandlerTestSuite) TestTokenHandler() { + token := &TokenAuthHandler{} + suite.Equal(AuthModeOAuth, token.Mode(), "auth mode token") + r, err := http.NewRequest(http.MethodGet, "https://p2p.token.com", nil) + require.NoError(suite.T(), err, "new HTTP request") + cred := &Credential{ + Mode: AuthModeOAuth, + Data: map[string]string{ + "token": "my-token", + }, + } + err = token.Authorize(r, cred) + require.NoError(suite.T(), err, "authorize HTTP request") + suite.Equal("Bearer my-token", r.Header.Get(authorizationHeader), "check token authorization header") +} + +// TestCustomHandler test custom auth handler +func (suite *AuthHandlerTestSuite) TestCustomHandler() { + custom := &CustomAuthHandler{} + suite.Equal(AuthModeCustom, custom.Mode(), "auth mode custom") + r, err := http.NewRequest(http.MethodGet, "https://p2p.custom.com", nil) + require.NoError(suite.T(), err, "new HTTP request") + cred := &Credential{ + Mode: AuthModeCustom, + Data: map[string]string{ + "api-key": "my-api-key", + }, + } + err = custom.Authorize(r, cred) + require.NoError(suite.T(), err, "authorize HTTP request") + suite.Equal("my-api-key", r.Header.Get("api-key"), "check custom authorization header") +} diff --git a/src/pkg/p2p/preheat/provider/auth/known_handlers.go b/src/pkg/p2p/preheat/provider/auth/known_handlers.go new file mode 100644 index 000000000..305227e10 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/known_handlers.go @@ -0,0 +1,16 @@ +package auth + +// Static handler registry +var knownHandlers = map[string]Handler{ + AuthModeNone: &NoneAuthHandler{}, + AuthModeBasic: &BasicAuthHandler{&BaseHandler{}}, + AuthModeCustom: &CustomAuthHandler{&BaseHandler{}}, + AuthModeOAuth: &TokenAuthHandler{&BaseHandler{}}, +} + +// GetAuthHandler gets the handler per the mode +func GetAuthHandler(mode string) (Handler, bool) { + h, ok := knownHandlers[mode] + + return h, ok +} diff --git a/src/pkg/p2p/preheat/provider/auth/none_handler.go b/src/pkg/p2p/preheat/provider/auth/none_handler.go new file mode 100644 index 000000000..efa76bbce --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/none_handler.go @@ -0,0 +1,24 @@ +package auth + +import ( + "errors" + "net/http" +) + +// NoneAuthHandler handles the case of no credentail required. +type NoneAuthHandler struct{} + +// Mode implements @Handler.Mode +func (nah *NoneAuthHandler) Mode() string { + return AuthModeNone +} + +// Authorize implements @Handler.Authorize +func (nah *NoneAuthHandler) Authorize(req *http.Request, cred *Credential) error { + if req == nil { + return errors.New("nil request cannot be authorized") + } + + // Do nothing + return nil +} diff --git a/src/pkg/p2p/preheat/provider/auth/token_handler.go b/src/pkg/p2p/preheat/provider/auth/token_handler.go new file mode 100644 index 000000000..bcef01230 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/auth/token_handler.go @@ -0,0 +1,33 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" +) + +// TokenAuthHandler handles the OAuth auth mode. +type TokenAuthHandler struct { + *BaseHandler +} + +// Mode implements @Handler.Mode +func (t *TokenAuthHandler) Mode() string { + return AuthModeOAuth +} + +// Authorize implements @Handler.Authorize +func (t *TokenAuthHandler) Authorize(req *http.Request, cred *Credential) error { + if err := t.BaseHandler.Authorize(req, cred); err != nil { + return err + } + + if _, ok := cred.Data["token"]; !ok { + return errors.New("missing OAuth token") + } + + authData := fmt.Sprintf("%s %s", "Bearer", cred.Data["token"]) + req.Header.Set("Authorization", authData) + + return nil +} diff --git a/src/pkg/p2p/preheat/provider/client/http_client.go b/src/pkg/p2p/preheat/provider/client/http_client.go new file mode 100644 index 000000000..789363c03 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/client/http_client.go @@ -0,0 +1,213 @@ +package client + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/goharbor/harbor/src/lib/log" + + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" +) + +const ( + clientTimeout = 10 * time.Second + maxIdleConnections = 20 + idleConnectionTimeout = 30 * time.Second + tlsHandshakeTimeout = 30 * time.Second +) + +// DefaultHTTPClient is used as the default http client. +var defaultHTTPClient, defaultInsecureHTTPClient *HTTPClient + +// GetHTTPClient returns the singleton HTTP client based on the insecure setting. +func GetHTTPClient(insecure bool) *HTTPClient { + if insecure { + if defaultInsecureHTTPClient == nil { + defaultInsecureHTTPClient = NewHTTPClient(insecure) + } + + return defaultInsecureHTTPClient + } + + if defaultHTTPClient == nil { + defaultHTTPClient = NewHTTPClient(insecure) + } + + return defaultHTTPClient +} + +// HTTPClient help to send http requests +type HTTPClient struct { + // http client + internalClient *http.Client +} + +// NewHTTPClient creates a new http client. +func NewHTTPClient(insecure bool) *HTTPClient { + client := &http.Client{ + Timeout: clientTimeout, + Transport: &http.Transport{ + MaxIdleConns: maxIdleConnections, + IdleConnTimeout: idleConnectionTimeout, + TLSHandshakeTimeout: tlsHandshakeTimeout, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + }, + } + + return &HTTPClient{ + internalClient: client, + } +} + +// Get content from the url +func (hc *HTTPClient) Get(url string, cred *auth.Credential, parmas map[string]string, options map[string]string) ([]byte, error) { + bytes, err := hc.get(url, cred, parmas, options) + logMsg := fmt.Sprintf("Get %s with cred=%v, params=%v, options=%v", url, cred, parmas, options) + if err != nil { + log.Errorf("%s: %s", logMsg, err) + } else { + log.Debugf("%s succeed: %s", logMsg, string(bytes)) + } + + return bytes, err +} + +func (hc *HTTPClient) get(url string, cred *auth.Credential, parmas map[string]string, options map[string]string) ([]byte, error) { + if len(url) == 0 { + return nil, errors.New("empty url") + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + if len(parmas) > 0 { + l := []string{} + for k, p := range parmas { + l = append(l, fmt.Sprintf("%s=%s", k, p)) + } + + req.URL.RawQuery = strings.Join(l, "&") + } + + if len(options) > 0 { + for k, h := range options { + req.Header.Add(k, h) + } + } + // Explicitly declare JSON data accepted. + req.Header.Set("Accept", "application/json") + + // Do auth + if err := hc.authorize(req, cred); err != nil { + return nil, err + } + + res, err := hc.internalClient.Do(req) + if err != nil { + return nil, err + } + + // If failed, read error message; if succeeded, read content. + defer res.Body.Close() + bytes, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if (res.StatusCode / 100) != 2 { + // Return the server error content in the error. + return nil, fmt.Errorf("%s '%s' error: %s %s", http.MethodGet, res.Request.URL.String(), res.Status, bytes) + } + + return bytes, nil +} + +// Post content to the service specified by the url +func (hc *HTTPClient) Post(url string, cred *auth.Credential, body interface{}, options map[string]string) ([]byte, error) { + bytes, err := hc.post(url, cred, body, options) + logMsg := fmt.Sprintf("Post %s with cred=%v, options=%v", url, cred, options) + if err != nil { + log.Errorf("%s: %s", logMsg, err) + } else { + log.Debugf("%s succeed: %s", logMsg, string(bytes)) + } + + return bytes, err +} + +func (hc *HTTPClient) post(url string, cred *auth.Credential, body interface{}, options map[string]string) ([]byte, error) { + if len(url) == 0 { + return nil, errors.New("empty url") + } + + // Marshal body to json data. + var bodyContent *strings.Reader + if body != nil { + content, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("only JSON data supported: %s", err) + } + + bodyContent = strings.NewReader(string(content)) + log.Debugf("POST body: %s", string(content)) + } + req, err := http.NewRequest(http.MethodPost, url, bodyContent) + if err != nil { + return nil, err + } + + if len(options) > 0 { + for k, h := range options { + req.Header.Add(k, h) + } + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Do auth + if err := hc.authorize(req, cred); err != nil { + return nil, err + } + + res, err := hc.internalClient.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + bytes, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if (res.StatusCode / 100) != 2 { + // Return the server error content in the error. + return nil, fmt.Errorf("%s '%s' error: %s %s", http.MethodPost, res.Request.URL.String(), res.Status, bytes) + } + + return bytes, nil +} + +func (hc *HTTPClient) authorize(req *http.Request, cred *auth.Credential) error { + if cred != nil { + authorizer, ok := auth.GetAuthHandler(cred.Mode) + if !ok { + return fmt.Errorf("no auth handler registered for mode: %s", cred.Mode) + } + if err := authorizer.Authorize(req, cred); err != nil { + return err + } + } + + return nil +} diff --git a/src/pkg/p2p/preheat/provider/client/http_client_test.go b/src/pkg/p2p/preheat/provider/client/http_client_test.go new file mode 100644 index 000000000..b06072ecf --- /dev/null +++ b/src/pkg/p2p/preheat/provider/client/http_client_test.go @@ -0,0 +1,101 @@ +// 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 client + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" + "github.com/stretchr/testify/suite" +) + +// HTTPClientTestSuite is a test suite for testing the HTTP client. +type HTTPClientTestSuite struct { + suite.Suite + + ts *httptest.Server +} + +// TestHTTPClient is the entry of running HttpClientTestSuite. +func TestHTTPClient(t *testing.T) { + suite.Run(t, &HTTPClientTestSuite{}) +} + +// SetupSuite prepares the env for the test suite. +func (suite *HTTPClientTestSuite) SetupSuite() { + suite.ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a := r.Header.Get("Authorization") + if len(a) == 0 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.Header().Add("Content-type", "application/json") + _, _ = w.Write([]byte("{}")) + })) + + suite.ts.StartTLS() +} + +// TearDownSuite clears the env for the test suite. +func (suite *HTTPClientTestSuite) TearDownSuite() { + suite.ts.Close() +} + +// TestClientGet tests the client getter method. +func (suite *HTTPClientTestSuite) TestClientGet() { + c := GetHTTPClient(true) + suite.NotNil(c, "get insecure HTTP client") + t := c.internalClient.Transport.(*http.Transport) + suite.Equal(true, t.TLSClientConfig.InsecureSkipVerify, "InsecureSkipVerify=true") + + c2 := GetHTTPClient(false) + suite.NotNil(c2, "get secure HTTP client") + t2 := c2.internalClient.Transport.(*http.Transport) + suite.Equal(false, t2.TLSClientConfig.InsecureSkipVerify, "InsecureSkipVerify=false") +} + +// TestGet test the Get method +func (suite *HTTPClientTestSuite) TestGet() { + c := GetHTTPClient(true) + suite.NotNil(c, "get insecure HTTP client") + + _, err := c.Get(suite.ts.URL, nil, nil, nil) + suite.Error(err, "unauthorized error", err) + + cred := &auth.Credential{ + Mode: auth.AuthModeBasic, + Data: map[string]string{"username": "password"}, + } + data, err := c.Get(suite.ts.URL, cred, map[string]string{"name": "TestGet"}, map[string]string{"Accept": "application/json"}) + suite.NoError(err, "get data") + suite.Equal("{}", string(data), "get json data") +} + +// TestPost test the Post method +func (suite *HTTPClientTestSuite) TestPost() { + c := GetHTTPClient(true) + suite.NotNil(c, "get insecure HTTP client") + + cred := &auth.Credential{ + Mode: auth.AuthModeBasic, + Data: map[string]string{"username": "password"}, + } + data, err := c.Post(suite.ts.URL, cred, []byte("{}"), map[string]string{"Accept": "application/json"}) + suite.NoError(err, "post data") + suite.Equal("{}", string(data), "post json data") +} diff --git a/src/pkg/p2p/preheat/provider/dragonfly.go b/src/pkg/p2p/preheat/provider/dragonfly.go new file mode 100644 index 000000000..b0c5b5289 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/dragonfly.go @@ -0,0 +1,141 @@ +package provider + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/goharbor/harbor/src/pkg/p2p/preheat/models/provider" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/client" +) + +const ( + healthCheckEndpoint = "/_ping" + preheatEndpoint = "/preheats" + preheatTaskEndpoint = "/preheats/{task_id}" + dragonflyPending = "WAITING" +) + +type dragonflyPreheatCreateResp struct { + ID string `json:"ID"` +} + +type dragonflyPreheatInfo struct { + ID string `json:"ID"` + StartTime string `json:"startTime,omitempty"` + FinishTime string `json:"finishTime,omitempty"` + Status string +} + +// DragonflyDriver implements the provider driver interface for Alibaba dragonfly. +// More details, please refer to https://github.com/alibaba/Dragonfly +type DragonflyDriver struct { + instance *provider.Instance +} + +// Self implements @Driver.Self. +func (dd *DragonflyDriver) Self() *Metadata { + return &Metadata{ + ID: "dragonfly", + Name: "Dragonfly", + Icon: "https://raw.githubusercontent.com/alibaba/Dragonfly/master/docs/images/logo.png", + Version: "0.10.1", + Source: "https://github.com/alibaba/Dragonfly", + Maintainers: []string{"Jin Zhang/taiyun.zj@alibaba-inc.com"}, + } +} + +// GetHealth implements @Driver.GetHealth. +func (dd *DragonflyDriver) GetHealth() (*DriverStatus, error) { + if dd.instance == nil { + return nil, errors.New("missing instance metadata") + } + + url := fmt.Sprintf("%s%s", strings.TrimSuffix(dd.instance.Endpoint, "/"), healthCheckEndpoint) + _, err := client.GetHTTPClient(dd.instance.Insecure).Get(url, dd.getCred(), nil, nil) + if err != nil { + // Unhealthy + return nil, err + } + + // For Dragonfly, no error returned means healthy + return &DriverStatus{ + Status: DriverStatusHealthy, + }, nil +} + +// Preheat implements @Driver.Preheat. +func (dd *DragonflyDriver) Preheat(preheatingImage *PreheatImage) (*PreheatingStatus, error) { + if dd.instance == nil { + return nil, errors.New("missing instance metadata") + } + + if preheatingImage == nil { + return nil, errors.New("no image specified") + } + + url := fmt.Sprintf("%s%s", strings.TrimSuffix(dd.instance.Endpoint, "/"), preheatEndpoint) + bytes, err := client.GetHTTPClient(dd.instance.Insecure).Post(url, dd.getCred(), preheatingImage, nil) + if err != nil { + return nil, err + } + + result := &dragonflyPreheatCreateResp{} + if err := json.Unmarshal(bytes, result); err != nil { + return nil, err + } + + return &PreheatingStatus{ + TaskID: result.ID, + Status: provider.PreheatingStatusPending, // default + }, nil +} + +// CheckProgress implements @Driver.CheckProgress. +func (dd *DragonflyDriver) CheckProgress(taskID string) (*PreheatingStatus, error) { + if dd.instance == nil { + return nil, errors.New("missing instance metadata") + } + + if len(taskID) == 0 { + return nil, errors.New("no task ID") + } + + path := strings.Replace(preheatTaskEndpoint, "{task_id}", taskID, 1) + url := fmt.Sprintf("%s%s", strings.TrimSuffix(dd.instance.Endpoint, "/"), path) + bytes, err := client.GetHTTPClient(dd.instance.Insecure).Get(url, dd.getCred(), nil, nil) + if err != nil { + return nil, err + } + + status := &dragonflyPreheatInfo{} + if err := json.Unmarshal(bytes, status); err != nil { + return nil, err + } + + if status.Status == dragonflyPending { + status.Status = provider.PreheatingStatusPending + } + + res := &PreheatingStatus{ + Status: status.Status, + TaskID: taskID, + } + if status.StartTime != "" { + res.StartTime = status.StartTime + } + if status.FinishTime != "" { + res.FinishTime = status.FinishTime + } + + return res, nil +} + +func (dd *DragonflyDriver) getCred() *auth.Credential { + return &auth.Credential{ + Mode: dd.instance.AuthMode, + Data: dd.instance.AuthInfo, + } +} diff --git a/src/pkg/p2p/preheat/provider/dragonfly_test.go b/src/pkg/p2p/preheat/provider/dragonfly_test.go new file mode 100644 index 000000000..bdd491d53 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/dragonfly_test.go @@ -0,0 +1,156 @@ +// 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 provider + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/goharbor/harbor/src/pkg/p2p/preheat/models/provider" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// DragonflyTestSuite is a test suite of testing Dragonfly driver. +type DragonflyTestSuite struct { + suite.Suite + + dragonfly *httptest.Server + driver *DragonflyDriver +} + +// TestDragonfly is the entry method of running DragonflyTestSuite. +func TestDragonfly(t *testing.T) { + suite.Run(t, &DragonflyTestSuite{}) +} + +// SetupSuite prepares the env for DragonflyTestSuite. +func (suite *DragonflyTestSuite) SetupSuite() { + suite.dragonfly = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case healthCheckEndpoint: + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusNotImplemented) + return + } + + w.WriteHeader(http.StatusOK) + case preheatEndpoint: + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotImplemented) + return + } + + data, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + image := &PreheatImage{} + if err := json.Unmarshal(data, image); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + if image.Type == "image" && + image.URL == "https://harbor.com" && + image.ImageName == "busybox" && + image.Tag == "latest" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ID":"dragonfly-id"}`)) + return + } + + w.WriteHeader(http.StatusBadRequest) + case strings.Replace(preheatTaskEndpoint, "{task_id}", "dragonfly-id", 1): + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusNotImplemented) + return + } + status := &dragonflyPreheatInfo{ + ID: "dragonfly-id", + StartTime: time.Now().UTC().String(), + FinishTime: time.Now().Add(5 * time.Minute).UTC().String(), + Status: "SUCCESS", + } + bytes, _ := json.Marshal(status) + _, _ = w.Write(bytes) + default: + w.WriteHeader(http.StatusNotImplemented) + } + })) + + suite.dragonfly.StartTLS() + + suite.driver = &DragonflyDriver{ + instance: &provider.Instance{ + ID: 1, + Name: "test-instance", + Vendor: DriverDragonfly, + Endpoint: suite.dragonfly.URL, + AuthMode: auth.AuthModeNone, + Enabled: true, + Default: true, + Insecure: true, + Status: DriverStatusHealthy, + }, + } +} + +// TearDownSuite clears the env for DragonflyTestSuite. +func (suite *DragonflyTestSuite) TearDownSuite() { + suite.dragonfly.Close() +} + +// TestSelf tests Self method. +func (suite *DragonflyTestSuite) TestSelf() { + m := suite.driver.Self() + suite.Equal(DriverDragonfly, m.ID, "self metadata") +} + +// TestGetHealth tests GetHealth method. +func (suite *DragonflyTestSuite) TestGetHealth() { + st, err := suite.driver.GetHealth() + require.NoError(suite.T(), err, "get health") + suite.Equal(DriverStatusHealthy, st.Status, "healthy status") +} + +// TestPreheat tests Preheat method. +func (suite *DragonflyTestSuite) TestPreheat() { + st, err := suite.driver.Preheat(&PreheatImage{ + Type: "image", + ImageName: "busybox", + Tag: "latest", + URL: "https://harbor.com", + }) + require.NoError(suite.T(), err, "preheat image") + suite.Equal("dragonfly-id", st.TaskID, "preheat image result") +} + +// TestCheckProgress tests CheckProgress method. +func (suite *DragonflyTestSuite) TestCheckProgress() { + st, err := suite.driver.CheckProgress("dragonfly-id") + require.NoError(suite.T(), err, "get preheat status") + suite.Equal(provider.PreheatingStatusSuccess, st.Status, "preheat status") +} diff --git a/src/pkg/p2p/preheat/provider/driver.go b/src/pkg/p2p/preheat/provider/driver.go new file mode 100644 index 000000000..ecbef9b73 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/driver.go @@ -0,0 +1,78 @@ +package provider + +const ( + // DriverStatusHealthy represents the healthy status + DriverStatusHealthy = "Healthy" + + // DriverStatusUnHealthy represents the unhealthy status + DriverStatusUnHealthy = "Unhealthy" +) + +// Driver defines the capabilities one distribution provider should have. +// Includes: +// Self descriptor +// Health checking +// Preheat related : Preheat means transfer the preheating image to the network of distribution provider in advance. +type Driver interface { + // Self returns the metadata of the driver. + // The metadata includes: name, icon(optional), maintainers(optional), version and source repo. + Self() *Metadata + + // Try to get the health status of the driver. + // If succeed, a non nil status object will be returned; + // otherwise, a non nil error will be set. + GetHealth() (*DriverStatus, error) + + // Preheat the specified image + // If succeed, a non nil result object with preheating task id will be returned; + // otherwise, a non nil error will be set. + Preheat(preheatingImage *PreheatImage) (*PreheatingStatus, error) + + // Check the progress of the preheating process. + // If succeed, a non nil status object with preheating status will be returned; + // otherwise, a non nil error will be set. + CheckProgress(taskID string) (*PreheatingStatus, error) +} + +// Metadata contains the basic information of the provider. +type Metadata struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Maintainers []string `json:"maintainers,omitempty"` + Version string `json:"version"` + Source string `json:"source,omitempty"` +} + +// DriverStatus keeps the health status of driver. +type DriverStatus struct { + Status string `json:"status"` +} + +// PreheatImage contains related information which can help providers to get/pull the images. +type PreheatImage struct { + // The image content type, only support 'image' now + Type string `json:"type"` + + // The access URL of the preheating image + URL string `json:"url"` + + // The headers which will be sent to the above URL of preheating image + Headers map[string]interface{} `json:"headers"` + + // The image name + ImageName string `json:"image,omitempty"` + + // The tag + Tag string `json:"tag,omitempty"` +} + +// PreheatingStatus contains the related results/status of the preheating operation +// from the provider. +type PreheatingStatus struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + StartTime string `json:"start_time"` + FinishTime string `json:"finish_time"` +} diff --git a/src/pkg/p2p/preheat/provider/factory.go b/src/pkg/p2p/preheat/provider/factory.go new file mode 100644 index 000000000..e166bb767 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/factory.go @@ -0,0 +1,18 @@ +package provider + +import ( + "github.com/goharbor/harbor/src/pkg/p2p/preheat/models/provider" +) + +// Factory is responsible to create a new driver based on the metadata. +type Factory func(instance *provider.Instance) (Driver, error) + +// DragonflyFactory creates dragonfly driver +func DragonflyFactory(instance *provider.Instance) (Driver, error) { + return &DragonflyDriver{instance}, nil +} + +// KrakenFactory creates kraken driver +func KrakenFactory(instance *provider.Instance) (Driver, error) { + return &KrakenDriver{instance, nil}, nil +} diff --git a/src/pkg/p2p/preheat/provider/known_drivers.go b/src/pkg/p2p/preheat/provider/known_drivers.go new file mode 100644 index 000000000..7a4315569 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/known_drivers.go @@ -0,0 +1,51 @@ +package provider + +import ( + "sort" + "strings" +) + +const ( + // DriverDragonfly represents the driver for dragonfly + DriverDragonfly = "dragonfly" + // DriverKraken represents the driver for kraken + DriverKraken = "kraken" +) + +// knownDrivers is static driver Factory registry +var knownDrivers = map[string]Factory{ + DriverDragonfly: DragonflyFactory, + DriverKraken: KrakenFactory, +} + +// ListProviders returns all the registered drivers. +func ListProviders() ([]*Metadata, error) { + results := make([]*Metadata, 0) + + for _, f := range knownDrivers { + drv, err := f(nil) + if err != nil { + return nil, err + } + + results = append(results, drv.Self()) + } + + // Sort results + if len(results) > 1 { + sort.SliceIsSorted(results, func(i, j int) bool { + return strings.Compare(results[i].ID, results[j].ID) > 0 + }) + } + + return results, nil +} + +// GetProvider returns the driver factory identified by the ID. +// +// If exists, bool flag will be set to be true and a non-nil reference will be returned. +func GetProvider(ID string) (Factory, bool) { + f, ok := knownDrivers[ID] + + return f, ok +} diff --git a/src/pkg/p2p/preheat/provider/known_drivers_test.go b/src/pkg/p2p/preheat/provider/known_drivers_test.go new file mode 100644 index 000000000..b30e20752 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/known_drivers_test.go @@ -0,0 +1,33 @@ +package provider + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// KnownDriverTestSuite is a test suite of testing known driver related. +type KnownDriverTestSuite struct { + suite.Suite +} + +// TestKnownDriver is the entry of running KnownDriverTestSuite. +func TestKnownDriver(t *testing.T) { + suite.Run(t, &KnownDriverTestSuite{}) +} + +func (suite *KnownDriverTestSuite) TestListProviders() { + metadata, err := ListProviders() + require.NoError(suite.T(), err, "list providers") + suite.Equal(len(knownDrivers), len(metadata)) + suite.Equal(DriverDragonfly, metadata[0].ID) +} + +func (suite *KnownDriverTestSuite) TestGetProvider() { + f, ok := GetProvider(DriverDragonfly) + require.Equal(suite.T(), true, ok) + + _, err := f(nil) + suite.NoError(err, "dragonfly factory") +} diff --git a/src/pkg/p2p/preheat/provider/kraken.go b/src/pkg/p2p/preheat/provider/kraken.go new file mode 100644 index 000000000..073b3a962 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/kraken.go @@ -0,0 +1,137 @@ +package provider + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/docker/distribution/manifest/schema2" + cm "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/models/provider" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/client" + "github.com/goharbor/harbor/src/pkg/registry" +) + +const ( + krakenHealthPath = "/health" + krakenPreheatPath = "/registry/notifications" +) + +type digestFetcherFunc func(repoName, tag string) (string, error) + +// KrakenDriver implements the provider driver interface for Uber kraken. +// More details, please refer to https://github.com/uber/kraken +type KrakenDriver struct { + instance *provider.Instance + digestFetcher digestFetcherFunc +} + +// Self implements @Driver.Self. +func (kd *KrakenDriver) Self() *Metadata { + return &Metadata{ + ID: "kraken", + Name: "Kraken", + Icon: "https://github.com/uber/kraken/blob/master/assets/kraken-logo-color.svg", + Version: "0.1.3", + Source: "https://github.com/uber/kraken", + Maintainers: []string{"mmpei/peimingming@corp.netease.com"}, + } +} + +// GetHealth implements @Driver.GetHealth. +func (kd *KrakenDriver) GetHealth() (*DriverStatus, error) { + if kd.instance == nil { + return nil, errors.New("missing instance metadata") + } + + url := fmt.Sprintf("%s%s", strings.TrimSuffix(kd.instance.Endpoint, "/"), krakenHealthPath) + _, err := client.GetHTTPClient(kd.instance.Insecure).Get(url, kd.getCred(), nil, nil) + if err != nil { + // Unhealthy + return nil, err + } + + // For Kraken, no error returned means healthy + return &DriverStatus{ + Status: DriverStatusHealthy, + }, nil +} + +// Preheat implements @Driver.Preheat. +func (kd *KrakenDriver) Preheat(preheatingImage *PreheatImage) (*PreheatingStatus, error) { + if kd.instance == nil { + return nil, errors.New("missing instance metadata") + } + + if preheatingImage == nil { + return nil, errors.New("no image specified") + } + + url := fmt.Sprintf("%s%s", strings.TrimSuffix(kd.instance.Endpoint, "/"), krakenPreheatPath) + var events = make([]cm.Event, 0) + eventID := utils.GenerateRandomString() + if kd.digestFetcher == nil { + kd.digestFetcher = fetchDigest + } + digest, err := kd.digestFetcher(preheatingImage.ImageName, preheatingImage.Tag) + if err != nil { + return nil, err + } + event := cm.Event{ + ID: eventID, + TimeStamp: time.Now().UTC(), + Action: "push", + Target: &cm.Target{ + MediaType: schema2.MediaTypeManifest, + Digest: digest, + Repository: preheatingImage.ImageName, + URL: preheatingImage.URL, + Tag: preheatingImage.Tag, + }, + } + events = append(events, event) + var payload = cm.Notification{ + Events: events, + } + _, err = client.GetHTTPClient(kd.instance.Insecure).Post(url, kd.getCred(), payload, nil) + if err != nil { + return nil, err + } + + return &PreheatingStatus{ + TaskID: eventID, + Status: provider.PreheatingStatusSuccess, + FinishTime: time.Now().String(), + }, nil +} + +// CheckProgress implements @Driver.CheckProgress. +// TODO: This should be improved later +func (kd *KrakenDriver) CheckProgress(taskID string) (*PreheatingStatus, error) { + return &PreheatingStatus{ + TaskID: taskID, + Status: provider.PreheatingStatusSuccess, + FinishTime: time.Now().String(), + }, nil +} + +func (kd *KrakenDriver) getCred() *auth.Credential { + return &auth.Credential{ + Mode: kd.instance.AuthMode, + Data: kd.instance.AuthInfo, + } +} + +func fetchDigest(repoName, tag string) (string, error) { + exist, digest, err := registry.Cli.ManifestExist(repoName, tag) + if err != nil { + return "", err + } + if !exist { + return "", errors.New("image not found") + } + return digest, nil +} diff --git a/src/pkg/p2p/preheat/provider/kraken_test.go b/src/pkg/p2p/preheat/provider/kraken_test.go new file mode 100644 index 000000000..e619e6db0 --- /dev/null +++ b/src/pkg/p2p/preheat/provider/kraken_test.go @@ -0,0 +1,146 @@ +// 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 provider + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + cm "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/models/provider" + "github.com/goharbor/harbor/src/pkg/p2p/preheat/provider/auth" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// KrakenTestSuite is a test suite of testing Kraken driver. +type KrakenTestSuite struct { + suite.Suite + + kraken *httptest.Server + driver *KrakenDriver +} + +// TestKraken is the entry method of running KrakenTestSuite. +func TestKraken(t *testing.T) { + suite.Run(t, &KrakenTestSuite{}) +} + +// SetupSuite prepares the env for KrakenTestSuite. +func (suite *KrakenTestSuite) SetupSuite() { + suite.kraken = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case krakenHealthPath: + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusNotImplemented) + return + } + + w.WriteHeader(http.StatusOK) + case krakenPreheatPath: + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotImplemented) + return + } + + data, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + var payload = &cm.Notification{ + Events: []cm.Event{}, + } + + if err := json.Unmarshal(data, payload); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + if len(payload.Events) > 0 { + w.WriteHeader(http.StatusOK) + return + } + + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusNotImplemented) + } + })) + + suite.kraken.StartTLS() + + suite.driver = &KrakenDriver{ + instance: &provider.Instance{ + ID: 2, + Name: "test-instance2", + Vendor: DriverKraken, + Endpoint: suite.kraken.URL, + AuthMode: auth.AuthModeNone, + Enabled: true, + Default: true, + Insecure: true, + Status: DriverStatusHealthy, + }, + digestFetcher: func(repoName, tag string) (s string, e error) { + return "image@digest", nil + }, + } +} + +// TearDownSuite clears the env for KrakenTestSuite. +func (suite *KrakenTestSuite) TearDownSuite() { + suite.kraken.Close() +} + +// TestSelf tests Self method. +func (suite *KrakenTestSuite) TestSelf() { + m := suite.driver.Self() + suite.Equal(DriverKraken, m.ID, "self metadata") +} + +// TestGetHealth tests GetHealth method. +func (suite *KrakenTestSuite) TestGetHealth() { + st, err := suite.driver.GetHealth() + require.NoError(suite.T(), err, "get health") + suite.Equal(DriverStatusHealthy, st.Status, "healthy status") +} + +// TestPreheat tests Preheat method. +func (suite *KrakenTestSuite) TestPreheat() { + st, err := suite.driver.Preheat(&PreheatImage{ + Type: "image", + ImageName: "busybox", + Tag: "latest", + URL: "https://harbor.com", + }) + require.NoError(suite.T(), err, "preheat image") + suite.Equal(provider.PreheatingStatusSuccess, st.Status, "preheat image result") + suite.NotEmptyf(st.FinishTime, "finish time") +} + +// TestCheckProgress tests CheckProgress method. +func (suite *KrakenTestSuite) TestCheckProgress() { + st, err := suite.driver.CheckProgress("kraken-id") + require.NoError(suite.T(), err, "get preheat status") + suite.Equal(provider.PreheatingStatusSuccess, st.Status, "preheat status") + suite.NotEmptyf(st.FinishTime, "finish time") +}