feat(preheat):add preheat drivers

- define preheat driver interface
- implement dragonfly driver
- implememt kraken driver
- add related UT cases with testify framework
- fix #10870 #10871
- some code are picked up from the original P2P feat branch

Signed-off-by: Steven Zou <szou@vmware.com>
This commit is contained in:
Steven Zou 2020-06-25 23:24:43 +08:00
parent ce62d05321
commit df86ae1ad0
19 changed files with 1426 additions and 0 deletions

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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,
}
}

View File

@ -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")
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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")
}