feat: add imagesearch provider (#6447)

* feat: add imagesearch provider



* update



* update



* update



* update url and queries



* add func getProviderTypeByHost



---------

Signed-off-by: wenhaozhou <wenhaozhou@yunify.com>
Signed-off-by: hongming <coder.scala@gmail.com>
Co-authored-by: KubeSphere CI Bot <47586280+ks-ci-bot@users.noreply.github.com>
Co-authored-by: wenhaozhou <wenhaozhou@yunify.com>
This commit is contained in:
hongming
2025-03-19 10:47:58 +08:00
committed by GitHub
parent 8fa36615e1
commit d2a591ac75
8 changed files with 539 additions and 8 deletions

View File

@@ -26,6 +26,7 @@ import (
"kubesphere.io/kubesphere/pkg/apiserver/options"
"kubesphere.io/kubesphere/pkg/config"
"kubesphere.io/kubesphere/pkg/models/auth"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch"
resourcev1beta1 "kubesphere.io/kubesphere/pkg/models/resources/v1beta1"
"kubesphere.io/kubesphere/pkg/scheme"
genericoptions "kubesphere.io/kubesphere/pkg/server/options"
@@ -111,6 +112,10 @@ func (s *APIServerOptions) NewAPIServer(ctx context.Context) (*apiserver.APIServ
return nil, fmt.Errorf("unable to setup identity provider: %v", err)
}
if err := imagesearch.SharedImageSearchProviderController.WatchConfigurationChanges(ctx, apiServer.RuntimeCache); err != nil {
return nil, fmt.Errorf("unable to setup image search provider: %v", err)
}
if apiServer.ClusterClient, err = clusterclient.NewClusterClientSet(apiServer.RuntimeCache); err != nil {
return nil, fmt.Errorf("unable to create cluster client: %v", err)
}

View File

@@ -14,11 +14,15 @@ import (
"github.com/emicklei/go-restful/v3"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/klog/v2"
"kubesphere.io/kubesphere/pkg/api"
"kubesphere.io/kubesphere/pkg/apiserver/query"
"kubesphere.io/kubesphere/pkg/models/components"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch/dockerhub"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch/harbor"
v2 "kubesphere.io/kubesphere/pkg/models/registries/v2"
resourcev1alpha3 "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/resource"
"kubesphere.io/kubesphere/pkg/simple/client/overview"
@@ -41,10 +45,12 @@ var (
)
type handler struct {
resourceGetterV1alpha3 *resourcev1alpha3.Getter
componentsGetter components.Getter
registryHelper v2.RegistryHelper
counter overview.Counter
resourceGetterV1alpha3 *resourcev1alpha3.Getter
componentsGetter components.Getter
registryHelper v2.RegistryHelper
counter overview.Counter
imageSearchController *imagesearch.Controller
imageSearchSecretGetter imagesearch.SecretGetter
}
func (h *handler) GetResources(request *restful.Request, response *restful.Response) {
@@ -221,6 +227,49 @@ func (h *handler) GetNamespaceOverview(request *restful.Request, response *restf
_ = response.WriteEntity(metrics)
}
func (h *handler) SearchImages(request *restful.Request, response *restful.Response) {
imageName := request.QueryParameter("q")
namespace := request.PathParameter("namespace")
searchSecret := request.QueryParameter("secret")
var (
config = &imagesearch.SearchConfig{}
err error
provider imagesearch.SearchProvider
)
if searchSecret != "" {
config, err = h.imageSearchSecretGetter.GetSecretConfig(request.Request.Context(), searchSecret, namespace)
if err != nil {
api.HandleError(response, request, err)
return
}
if config.ProviderType == "" {
config.ProviderType = getProviderTypeByHost(config.Host)
}
var exist bool
provider, exist = h.imageSearchController.GetProvider(config.ProviderType)
if !exist {
api.HandleNotFound(response, request, errors.NewNotFound(schema.GroupResource{
Resource: "imageSearchProvider",
}, config.ProviderType))
return
}
} else {
provider = h.imageSearchController.GetDefaultProvider()
}
results, err := provider.Search(imageName, *config)
if err != nil {
api.HandleError(response, request, err)
return
}
_ = response.WriteEntity(results)
}
func canonicalizeRegistryError(request *restful.Request, response *restful.Response, err error) {
if strings.Contains(err.Error(), "Unauthorized") {
api.HandleUnauthorized(response, request, err)
@@ -230,3 +279,10 @@ func canonicalizeRegistryError(request *restful.Request, response *restful.Respo
api.HandleBadRequest(response, request, err)
}
}
func getProviderTypeByHost(host string) string {
if host == imagesearch.HostDockerIo {
return dockerhub.DockerHubRegisterProvider
}
return harbor.HarborRegisterProvider
}

View File

@@ -24,6 +24,8 @@ import (
v2 "kubesphere.io/kubesphere/pkg/models/registries/v2"
resourcev1alpha3 "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/resource"
"kubesphere.io/kubesphere/pkg/simple/client/overview"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch"
)
const (
@@ -39,10 +41,12 @@ func Resource(resource string) schema.GroupResource {
func NewHandler(cacheReader runtimeclient.Reader, counter overview.Counter, k8sVersion *semver.Version) rest.Handler {
return &handler{
resourceGetterV1alpha3: resourcev1alpha3.NewResourceGetter(cacheReader, k8sVersion),
componentsGetter: components.NewComponentsGetter(cacheReader),
registryHelper: v2.NewRegistryHelper(),
counter: counter,
resourceGetterV1alpha3: resourcev1alpha3.NewResourceGetter(cacheReader, k8sVersion),
componentsGetter: components.NewComponentsGetter(cacheReader),
registryHelper: v2.NewRegistryHelper(),
imageSearchController: imagesearch.SharedImageSearchProviderController,
counter: counter,
imageSearchSecretGetter: imagesearch.NewSecretGetter(cacheReader),
}
}
@@ -137,6 +141,15 @@ func (h *handler) AddToContainer(c *restful.Container) error {
Reads(v1.Secret{}).
Returns(http.StatusOK, api.StatusOK, v1.Secret{}))
ws.Route(ws.GET("/namespaces/{namespace}/images").
To(h.SearchImages).
Doc("Search image from a registry").
Metadata(restfulspec.KeyOpenAPITags, []string{api.TagNamespacedResources}).
Param(ws.PathParameter("namespace", "The specified namespace.")).
Param(ws.QueryParameter("secret", "Secret name of the image repository credential, left empty means anonymous fetch.").Required(false)).
Param(ws.QueryParameter("q", "Search parameter for project and repository name.")).
Returns(http.StatusOK, api.StatusOK, imagesearch.Results{}))
ws.Route(ws.GET("/namespaces/{namespace}/imageconfig").
To(h.GetImageConfig).
Deprecate().

View File

@@ -0,0 +1,36 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package imagesearch
import (
"fmt"
"gopkg.in/yaml.v3"
v1 "k8s.io/api/core/v1"
)
const (
SecretDataKey = "configuration.yaml"
)
type Configuration struct {
// The provider name.
Name string `json:"name" yaml:"name"`
// The type of image search provider
Type string `json:"type" yaml:"type"`
// The options of image search provider
ProviderOptions map[string]interface{} `json:"provider" yaml:"provider"`
}
func UnmarshalFrom(secret *v1.Secret) (*Configuration, error) {
config := &Configuration{}
if err := yaml.Unmarshal(secret.Data[SecretDataKey], config); err != nil {
return nil, fmt.Errorf("failed to unmarshal secret data: %s", err)
}
return config, nil
}

View File

@@ -0,0 +1,132 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package imagesearch
import (
"context"
"fmt"
"sync"
v1 "k8s.io/api/core/v1"
toolscache "k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
runtimecache "sigs.k8s.io/controller-runtime/pkg/cache"
"kubesphere.io/kubesphere/pkg/constants"
)
var SharedImageSearchProviderController = NewController()
const (
dockerHubRegisterProvider = "DockerHubRegistryProvider"
harborRegisterProvider = "HarborRegistryProvider"
SecretTypeImageSearchProvider = "config.kubesphere.io/imagesearchprovider"
)
type Controller struct {
imageSearchProviders *sync.Map
imageSearchProviderConfig *sync.Map
}
func NewController() *Controller {
return &Controller{
imageSearchProviders: &sync.Map{},
imageSearchProviderConfig: &sync.Map{}}
}
func (c *Controller) WatchConfigurationChanges(ctx context.Context, cache runtimecache.Cache) error {
informer, err := cache.GetInformer(ctx, &v1.Secret{})
if err != nil {
return fmt.Errorf("get informer failed: %w", err)
}
c.initGenericProvider()
_, err = informer.AddEventHandler(toolscache.FilteringResourceEventHandler{
FilterFunc: func(obj interface{}) bool {
return IsImageSearchProviderConfiguration(obj.(*v1.Secret))
},
Handler: &toolscache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
c.OnConfigurationChange(obj.(*v1.Secret))
},
UpdateFunc: func(old, new interface{}) {
c.OnConfigurationChange(new.(*v1.Secret))
},
DeleteFunc: func(obj interface{}) {
c.OnConfigurationDelete(obj.(*v1.Secret))
},
},
})
if err != nil {
return fmt.Errorf("add event handler failed: %w", err)
}
return nil
}
func (c *Controller) GetDefaultProvider() SearchProvider {
provider, _ := c.imageSearchProviders.Load(dockerHubRegisterProvider)
return provider.(SearchProvider)
}
func (c *Controller) initGenericProvider() {
dockerHubProvider, _ := searchProviderFactories[dockerHubRegisterProvider].Create(nil)
c.imageSearchProviders.Store(dockerHubRegisterProvider, dockerHubProvider)
harborProvider, _ := searchProviderFactories[harborRegisterProvider].Create(nil)
c.imageSearchProviders.Store(harborRegisterProvider, harborProvider)
}
func IsImageSearchProviderConfiguration(secret *v1.Secret) bool {
if secret.Namespace != constants.KubeSphereNamespace {
return false
}
return secret.Type == SecretTypeImageSearchProvider
}
func (c *Controller) OnConfigurationDelete(secret *v1.Secret) {
configuration, err := UnmarshalFrom(secret)
if err != nil {
klog.Errorf("failed to unmarshal secret data: %s", err)
return
}
c.imageSearchProviders.Delete(configuration.Name)
c.imageSearchProviderConfig.Delete(configuration.Name)
}
func (c *Controller) OnConfigurationChange(secret *v1.Secret) {
configuration, err := UnmarshalFrom(secret)
if err != nil {
klog.Errorf("failed to unmarshal secret data: %s", err)
return
}
if factory, ok := searchProviderFactories[configuration.Type]; ok {
if provider, err := factory.Create(configuration.ProviderOptions); err != nil {
klog.Error(fmt.Sprintf("failed to create image search provider %s: %s", configuration.Name, err))
} else {
c.imageSearchProviders.Store(configuration.Name, provider)
c.imageSearchProviderConfig.Store(configuration.Name, configuration)
klog.V(4).Infof("create image search provider %s successfully", configuration.Name)
}
} else {
klog.Errorf("image search provider %s with type %s is not supported", configuration.Name, configuration.Type)
return
}
}
func (c *Controller) GetProvider(providerName string) (SearchProvider, bool) {
if obj, ok := c.imageSearchProviders.Load(providerName); ok {
if provider, ok := obj.(SearchProvider); ok {
return provider, true
}
}
return nil, false
}

View File

@@ -0,0 +1,99 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package dockerhub
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"k8s.io/klog/v2"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch"
)
const (
DockerHubRegisterProvider = "DockerHubRegistryProvider"
dockerHubSearchUrl = "v2/search/repositories?query=%s"
dockerHubHost = "https://hub.docker.com"
)
func init() {
imagesearch.RegistrySearchProvider(&dockerHubSearchProviderFactory{})
}
var _ imagesearch.SearchProvider = &dockerHubSearchProvider{}
type dockerHubSearchProvider struct {
HttpClient *http.Client `json:"-" yaml:"-"`
}
type searchResponse struct {
Results []result `json:"results"`
}
type result struct {
RepoName string `json:"repo_name"`
}
func (d dockerHubSearchProvider) Search(imageName string, config imagesearch.SearchConfig) (*imagesearch.Results, error) {
url := fmt.Sprintf("%s/%s", dockerHubHost, fmt.Sprintf(dockerHubSearchUrl, imageName))
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if config.Username != "" {
authCode := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", config.Username, config.Password))))
request.Header.Set("Authorization", authCode)
}
resp, err := d.HttpClient.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
klog.Errorf("search images failed with status code: %d, %s", resp.StatusCode, string(bytes))
return nil, fmt.Errorf("search images failed with status code: %d", resp.StatusCode)
}
searchResp := &searchResponse{}
err = json.Unmarshal(bytes, searchResp)
if err != nil {
return nil, err
}
imageResult := &imagesearch.Results{
Entries: make([]string, 0),
}
for _, v := range searchResp.Results {
imageResult.Entries = append(imageResult.Entries, v.RepoName)
}
imageResult.Total = int64(len(imageResult.Entries))
return imageResult, nil
}
var _ imagesearch.SearchProviderFactory = &dockerHubSearchProviderFactory{}
type dockerHubSearchProviderFactory struct{}
func (d dockerHubSearchProviderFactory) Type() string {
return DockerHubRegisterProvider
}
func (d dockerHubSearchProviderFactory) Create(_ map[string]interface{}) (imagesearch.SearchProvider, error) {
var provider dockerHubSearchProvider
provider.HttpClient = http.DefaultClient
return provider, nil
}

View File

@@ -0,0 +1,106 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package harbor
import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"k8s.io/klog/v2"
"kubesphere.io/kubesphere/pkg/models/registries/imagesearch"
)
const (
HarborRegisterProvider = "HarborRegistryProvider"
harborSearchUrl = "api/v2.0/search?q=%s"
)
func init() {
imagesearch.RegistrySearchProvider(&harborRegistrySearchProviderFactory{})
}
var _ imagesearch.SearchProvider = &harborRegistrySearchProvider{}
type harborRegistrySearchProvider struct {
HttpClient *http.Client `json:"-" yaml:"-"`
}
type searchResponse struct {
Repository []repository `json:"repository"`
}
type repository struct {
RepositoryName string `json:"repository_name"`
}
func (d harborRegistrySearchProvider) Search(imageName string, config imagesearch.SearchConfig) (*imagesearch.Results, error) {
url := fmt.Sprintf("%s/%s", config.Host, fmt.Sprintf(harborSearchUrl, imageName))
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if config.Username != "" {
authCode := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", config.Username, config.Password))))
request.Header.Set("Authorization", authCode)
}
resp, err := d.HttpClient.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
klog.Errorf("search images failed with status code: %d, %s", resp.StatusCode, string(bytes))
return nil, fmt.Errorf("search images failed with status code: %d, message: %s", resp.StatusCode, bytes)
}
searchResp := &searchResponse{}
err = json.Unmarshal(bytes, searchResp)
if err != nil {
return nil, err
}
imageResult := &imagesearch.Results{
Entries: make([]string, 0),
}
for _, v := range searchResp.Repository {
imageResult.Entries = append(imageResult.Entries, v.RepositoryName)
}
imageResult.Total = int64(len(imageResult.Entries))
return imageResult, nil
}
var _ imagesearch.SearchProviderFactory = &harborRegistrySearchProviderFactory{}
type harborRegistrySearchProviderFactory struct{}
func (d harborRegistrySearchProviderFactory) Type() string {
return HarborRegisterProvider
}
func (d harborRegistrySearchProviderFactory) Create(_ map[string]interface{}) (imagesearch.SearchProvider, error) {
var provider harborRegistrySearchProvider
provider.HttpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
return provider, nil
}

View File

@@ -0,0 +1,84 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package imagesearch
import (
"context"
jsoniter "github.com/json-iterator/go"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
HostDockerIo = "https://docker.io"
)
var (
searchProviderFactories = make(map[string]SearchProviderFactory)
)
type SearchProvider interface {
Search(imageName string, config SearchConfig) (*Results, error)
}
type Results struct {
Total int64 `json:"total"`
Entries []string `json:"entries"`
}
type SearchConfig struct {
Host string
ProviderType string
Username string
Password string
}
type SearchProviderFactory interface {
Type() string
Create(options map[string]interface{}) (SearchProvider, error)
}
func RegistrySearchProvider(factory SearchProviderFactory) {
searchProviderFactories[factory.Type()] = factory
}
type SecretGetter interface {
GetSecretConfig(ctx context.Context, name, namespace string) (*SearchConfig, error)
}
func NewSecretGetter(reader client.Reader) SecretGetter {
return &secretGetter{reader}
}
type secretGetter struct {
client.Reader
}
// {"auths":{"https://harbor.172.31.19.17.nip.io":{"username":"admin","password":"Harbor12345","email":"","auth":"YWRtaW46SGFyYm9yMTIzNDU="}}}
func (s *secretGetter) GetSecretConfig(ctx context.Context, name, namespace string) (*SearchConfig, error) {
secret := &corev1.Secret{}
err := s.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret)
if err != nil {
return nil, err
}
provider := secret.Annotations[SecretTypeImageSearchProvider]
data := secret.Data[".dockerconfigjson"]
auths := jsoniter.Get(data, "auths")
host := auths.Keys()[0]
username := auths.Get(host, "username").ToString()
password := auths.Get(host, "password").ToString()
return &SearchConfig{
Host: host,
ProviderType: provider,
Username: username,
Password: password,
}, nil
}