diff --git a/cmd/ks-apiserver/app/options/options.go b/cmd/ks-apiserver/app/options/options.go index 2d495170b..c42841f8a 100644 --- a/cmd/ks-apiserver/app/options/options.go +++ b/cmd/ks-apiserver/app/options/options.go @@ -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) } diff --git a/pkg/kapis/resources/v1alpha3/handler.go b/pkg/kapis/resources/v1alpha3/handler.go index 2d4ca0527..d5c1c5472 100644 --- a/pkg/kapis/resources/v1alpha3/handler.go +++ b/pkg/kapis/resources/v1alpha3/handler.go @@ -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 +} diff --git a/pkg/kapis/resources/v1alpha3/register.go b/pkg/kapis/resources/v1alpha3/register.go index 3b2865c65..0b421fa56 100644 --- a/pkg/kapis/resources/v1alpha3/register.go +++ b/pkg/kapis/resources/v1alpha3/register.go @@ -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(). diff --git a/pkg/models/registries/imagesearch/configuration.go b/pkg/models/registries/imagesearch/configuration.go new file mode 100644 index 000000000..e008e3d9a --- /dev/null +++ b/pkg/models/registries/imagesearch/configuration.go @@ -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 +} diff --git a/pkg/models/registries/imagesearch/controller.go b/pkg/models/registries/imagesearch/controller.go new file mode 100644 index 000000000..f639c05fb --- /dev/null +++ b/pkg/models/registries/imagesearch/controller.go @@ -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 +} diff --git a/pkg/models/registries/imagesearch/dockerhub/provider.go b/pkg/models/registries/imagesearch/dockerhub/provider.go new file mode 100644 index 000000000..251299533 --- /dev/null +++ b/pkg/models/registries/imagesearch/dockerhub/provider.go @@ -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 +} diff --git a/pkg/models/registries/imagesearch/harbor/provider.go b/pkg/models/registries/imagesearch/harbor/provider.go new file mode 100644 index 000000000..aa54eec33 --- /dev/null +++ b/pkg/models/registries/imagesearch/harbor/provider.go @@ -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 +} diff --git a/pkg/models/registries/imagesearch/registry_provider.go b/pkg/models/registries/imagesearch/registry_provider.go new file mode 100644 index 000000000..fb2b40f3f --- /dev/null +++ b/pkg/models/registries/imagesearch/registry_provider.go @@ -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 +}