From a8fc4836806c3c8b91d29c961bcb64246f718ec4 Mon Sep 17 00:00:00 2001 From: Junjun Li Date: Wed, 29 May 2019 20:16:04 +0800 Subject: [PATCH] Signed-off-by: Junjun Li # Conflicts: # daemon/mgr/image.go --- apis/server/image_bridge.go | 16 ++++- apis/swagger.yml | 47 +++++++----- cli/main.go | 1 + cli/search.go | 102 +++++++++++++++++++++++++++ client/image_search.go | 32 +++++++++ client/image_search_test.go | 81 +++++++++++++++++++++ client/interface.go | 1 + daemon/mgr/image.go | 50 ++++++++++++- registry/types/search_result_resp.go | 16 +++++ test/api_image_search_test.go | 28 +++++++- test/cli_search_test.go | 43 +++++++++++ 11 files changed, 393 insertions(+), 24 deletions(-) create mode 100644 cli/search.go create mode 100644 client/image_search.go create mode 100644 client/image_search_test.go create mode 100644 registry/types/search_result_resp.go create mode 100644 test/cli_search_test.go diff --git a/apis/server/image_bridge.go b/apis/server/image_bridge.go index b821a7b5f..bf9b6b29e 100644 --- a/apis/server/image_bridge.go +++ b/apis/server/image_bridge.go @@ -17,6 +17,7 @@ import ( "github.com/alibaba/pouch/pkg/httputils" util_metrics "github.com/alibaba/pouch/pkg/utils/metrics" + "github.com/go-openapi/strfmt" "github.com/gorilla/mux" "github.com/opencontainers/go-digest" "github.com/sirupsen/logrus" @@ -93,7 +94,20 @@ func (s *Server) searchImages(ctx context.Context, rw http.ResponseWriter, req * searchPattern := req.FormValue("term") registry := req.FormValue("registry") - searchResultItem, err := s.ImageMgr.SearchImages(ctx, searchPattern, registry) + // get registry auth from Request header + authStr := req.Header.Get("X-Registry-Auth") + authConfig := types.AuthConfig{} + if authStr != "" { + data := base64.NewDecoder(base64.URLEncoding, strings.NewReader(authStr)) + if err := json.NewDecoder(data).Decode(&authConfig); err != nil { + return err + } + if err := authConfig.Validate(strfmt.NewFormats()); err != nil { + return err + } + } + + searchResultItem, err := s.ImageMgr.SearchImages(ctx, searchPattern, registry, &authConfig) if err != nil { logrus.Errorf("failed to search images from registry: %v", err) return err diff --git a/apis/swagger.yml b/apis/swagger.yml index 96fb79263..371a6472c 100644 --- a/apis/swagger.yml +++ b/apis/swagger.yml @@ -370,6 +370,17 @@ paths: $ref: "#/definitions/SearchResultItem" 500: $ref: "#/responses/500ErrorResponse" + parameters: + - name: "term" + in: "query" + description: "Term to search" + type: "string" + required: true + - name: "registry" + in: "query" + description: "Search images from specified registry" + type: "string" + # TODO: add limit and filters /images/{imageid}/tag: post: @@ -3166,24 +3177,24 @@ definitions: x-nullable: false SearchResultItem: - type: "object" - description: "search result item in search results." - properties: - description: - type: "string" - description: "description just shows the description of this image" - is_official: - type: "boolean" - description: "is_official shows if this image is marked official." - is_automated: - type: "boolean" - description: "is_automated means whether this image is automated." - name: - type: "string" - description: "name represents the name of this image" - star_count: - type: "integer" - description: "star_count refers to the star count of this image." + type: "object" + description: "search result item in search results." + properties: + description: + type: "string" + description: "description just shows the description of this image" + is_official: + type: "boolean" + description: "is_official shows if this image is marked official." + is_automated: + type: "boolean" + description: "is_automated means whether this image is automated." + name: + type: "string" + description: "name represents the name of this image" + star_count: + type: "integer" + description: "star_count refers to the star count of this image." VolumeInfo: type: "object" diff --git a/cli/main.go b/cli/main.go index 9a952e8f7..a731a46d5 100644 --- a/cli/main.go +++ b/cli/main.go @@ -35,6 +35,7 @@ func main() { cli.AddCommand(base, &LoadCommand{}) cli.AddCommand(base, &SaveCommand{}) cli.AddCommand(base, &HistoryCommand{}) + cli.AddCommand(base, &SearchCommand{}) cli.AddCommand(base, &InspectCommand{}) cli.AddCommand(base, &RenameCommand{}) diff --git a/cli/search.go b/cli/search.go new file mode 100644 index 000000000..f2c773360 --- /dev/null +++ b/cli/search.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +var searchDescription = "\nSearch the images from specific registry." + +// SearchCommand implements search images. +type SearchCommand struct { + baseCommand + registry string +} + +// Init initialize search command. +func (s *SearchCommand) Init(c *Cli) { + s.cli = c + + s.cmd = &cobra.Command{ + Use: "search [OPTIONS] TERM", + Short: "Search the images from specific registry", + Long: searchDescription, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return s.runSearch(args) + }, + Example: searchExample(), + } + s.addFlags() +} + +// addFlags adds flags for specific command. +func (s *SearchCommand) addFlags() { + flagSet := s.cmd.Flags() + + flagSet.StringVarP(&s.registry, "registry", "r", "", "set registry name") +} + +func (s *SearchCommand) runSearch(args []string) error { + ctx := context.Background() + apiClient := s.cli.Client() + + term := args[0] + + // TODO: add flags --filter、--format、--limit、--no-trunc + searchResults, err := apiClient.ImageSearch(ctx, term, s.registry, fetchRegistryAuth(s.registry)) + + if err != nil { + return err + } + + display := s.cli.NewTableDisplay() + display.AddRow([]string{"NAME", "DESCRIPTION", "STARS", "OFFICIAL", "AUTOMATED"}) + + for _, result := range searchResults { + display.AddRow([]string{result.Name, result.Description, fmt.Sprint(result.StarCount), boolToOKOrNot(result.IsOfficial), boolToOKOrNot(result.IsAutomated)}) + } + + display.Flush() + return nil +} + +func boolToOKOrNot(isTrue bool) string { + if isTrue { + return "[OK]" + } + return "" +} + +func searchExample() string { + return `$ pouch search nginx +NAME DESCRIPTION STARS OFFICIAL AUTOMATED +nginx Official build of Nginx. 11403 [OK] +jwilder/nginx-proxy Automated Nginx reverse proxy for docker con… 1600 [OK] +richarvey/nginx-php-fpm Container running Nginx + PHP-FPM capable of… 712 [OK] +jrcs/letsencrypt-nginx-proxy-companion LetsEncrypt container to use with nginx as p… 509 [OK] +webdevops/php-nginx Nginx with PHP-FPM 127 [OK] +zabbix/zabbix-web-nginx-mysql Zabbix frontend based on Nginx web-server wi… 101 [OK] +bitnami/nginx Bitnami nginx Docker Image 66 [OK] +linuxserver/nginx An Nginx container, brought to you by LinuxS… 61 +1and1internet/ubuntu-16-nginx-php-phpmyadmin-mysql-5 ubuntu-16-nginx-php-phpmyadmin-mysql-5 50 [OK] +zabbix/zabbix-web-nginx-pgsql Zabbix frontend based on Nginx with PostgreS… 33 [OK] +tobi312/rpi-nginx NGINX on Raspberry Pi / ARM 26 [OK] +nginx/nginx-ingress NGINX Ingress Controller for Kubernetes 20 +schmunk42/nginx-redirect A very simple container to redirect HTTP tra… 15 [OK] +nginxdemos/hello NGINX webserver that serves a simple page co… 14 [OK] +blacklabelops/nginx Dockerized Nginx Reverse Proxy Server. 12 [OK] +wodby/drupal-nginx Nginx for Drupal container image 12 [OK] +centos/nginx-18-centos7 Platform for running nginx 1.8 or building n… 10 +centos/nginx-112-centos7 Platform for running nginx 1.12 or building … 9 +nginxinc/nginx-unprivileged Unprivileged NGINX Dockerfiles 4 +1science/nginx Nginx Docker images that include Consul Temp… 4 [OK] +nginx/nginx-prometheus-exporter NGINX Prometheus Exporter 4 +mailu/nginx Mailu nginx frontend 3 [OK] +toccoag/openshift-nginx Nginx reverse proxy for Nice running on same… 1 [OK] +ansibleplaybookbundle/nginx-apb An APB to deploy NGINX 0 [OK] +wodby/nginx Generic nginx 0 [OK] +` +} diff --git a/client/image_search.go b/client/image_search.go new file mode 100644 index 000000000..df184809a --- /dev/null +++ b/client/image_search.go @@ -0,0 +1,32 @@ +package client + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/alibaba/pouch/apis/types" +) + +// ImageSearch requests daemon to search an image from registry. +func (client *APIClient) ImageSearch(ctx context.Context, term, registry, encodedAuth string) ([]types.SearchResultItem, error) { + var results []types.SearchResultItem + + q := url.Values{} + q.Set("term", term) + q.Set("registry", registry) + + headers := map[string][]string{} + if encodedAuth != "" { + headers["X-Registry-Auth"] = []string{encodedAuth} + } + + resp, err := client.post(ctx, "/images/search", q, nil, headers) + + if err != nil { + return nil, err + } + + err = json.NewDecoder(resp.Body).Decode(&results) + return results, err +} diff --git a/client/image_search_test.go b/client/image_search_test.go new file mode 100644 index 000000000..1c20f9d66 --- /dev/null +++ b/client/image_search_test.go @@ -0,0 +1,81 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/alibaba/pouch/apis/types" + + "github.com/stretchr/testify/assert" +) + +func TestImageSearchServerError(t *testing.T) { + client := &APIClient{ + HTTPCli: newMockClient(errorMockResponse(http.StatusInternalServerError, "Server error")), + } + term, registry, auth := "", "nginx", "" + _, err := client.ImageSearch(context.Background(), term, registry, auth) + if err == nil || !strings.Contains(err.Error(), "Server error") { + t.Fatalf("expected a Server Error, got %v", err) + } +} + +func TestImageSearchOK(t *testing.T) { + expectedURL := "/images/search" + + httpClient := newMockClient(func(req *http.Request) (*http.Response, error) { + if !strings.HasPrefix(req.URL.Path, expectedURL) { + return nil, fmt.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL) + } + if req.Method != "POST" { + return nil, fmt.Errorf("expected POST method, got %s", req.Method) + } + + searchResultResp, err := json.Marshal([]types.SearchResultItem{ + { + Description: "nginx info", + IsAutomated: false, + IsOfficial: true, + Name: "nginx", + StarCount: 1233, + }, + }) + if err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(searchResultResp))), + }, nil + }) + + client := &APIClient{ + HTTPCli: httpClient, + } + + searchResultResp, err := client.ImageSearch(context.Background(), "nginx", "", "") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, searchResultResp[0].StarCount, int64(1233)) + assert.Equal(t, searchResultResp[0].Name, "nginx") +} + +func TestImageSearchStatusUnauthorizedError(t *testing.T) { + client := &APIClient{ + HTTPCli: newMockClient(errorMockResponse(http.StatusUnauthorized, "Unauthorized Error")), + } + term, registry, auth := "", "nginx", "some-auth-code" + _, err := client.ImageSearch(context.Background(), term, registry, auth) + if err == nil || !strings.Contains(err.Error(), "Unauthorized Error") { + t.Fatalf("expected a Unauthorized Error, got %v", err) + } +} diff --git a/client/interface.go b/client/interface.go index 7ddf17154..7301b5b67 100644 --- a/client/interface.go +++ b/client/interface.go @@ -59,6 +59,7 @@ type ImageAPIClient interface { ImageSave(ctx context.Context, imageName string) (io.ReadCloser, error) ImageHistory(ctx context.Context, name string) ([]types.HistoryResultItem, error) ImagePush(ctx context.Context, ref, encodedAuth string) (io.ReadCloser, error) + ImageSearch(ctx context.Context, term, registry, encodedAuth string) ([]types.SearchResultItem, error) } // VolumeAPIClient defines methods of Volume client. diff --git a/daemon/mgr/image.go b/daemon/mgr/image.go index cd076a02a..11930230b 100644 --- a/daemon/mgr/image.go +++ b/daemon/mgr/image.go @@ -2,10 +2,13 @@ package mgr import ( "context" + "encoding/json" "errors" "fmt" "io" + "io/ioutil" "net/http" + "net/url" "path" "strings" "time" @@ -20,6 +23,7 @@ import ( "github.com/alibaba/pouch/pkg/jsonstream" "github.com/alibaba/pouch/pkg/reference" "github.com/alibaba/pouch/pkg/utils" + searchtypes "github.com/alibaba/pouch/registry/types" "github.com/containerd/containerd" "github.com/containerd/containerd/content" @@ -62,7 +66,7 @@ type ImageMgr interface { ListImages(ctx context.Context, filter filters.Args) ([]types.ImageInfo, error) // Search Images from specified registry. - SearchImages(ctx context.Context, name string, registry string) ([]types.SearchResultItem, error) + SearchImages(ctx context.Context, name, registry string, authConfig *types.AuthConfig) ([]types.SearchResultItem, error) // RemoveImage deletes an image by reference. RemoveImage(ctx context.Context, idOrRef string, force bool) error @@ -389,9 +393,49 @@ func (mgr *ImageManager) ListImages(ctx context.Context, filter filters.Args) ([ } // SearchImages searches imaged from specified registry. -func (mgr *ImageManager) SearchImages(ctx context.Context, name string, registry string) ([]types.SearchResultItem, error) { +func (mgr *ImageManager) SearchImages(ctx context.Context, name, registry string, auth *types.AuthConfig) ([]types.SearchResultItem, error) { // Directly send API calls towards specified registry - return nil, errtypes.ErrNotImplemented + if len(registry) == 0 { + registry = "https://" + mgr.DefaultRegistry + "/v1/" + } + + u := registry + "search?q=" + url.QueryEscape(name) + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + + if auth != nil && auth.Username != "" && auth.Password != "" { + req.SetBasicAuth(auth.Username, auth.Password) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("unexepected status code %d", res.StatusCode) + } + + rawData, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var searchResultResp searchtypes.SearchResultResp + var result []types.SearchResultItem + err = json.Unmarshal(rawData, &searchResultResp) + if err != nil { + return nil, err + } + + // TODO: sort results by count num + for _, searchResultItem := range searchResultResp.Results { + result = append(result, *searchResultItem) + } + return result, err } // RemoveImage deletes a reference. diff --git a/registry/types/search_result_resp.go b/registry/types/search_result_resp.go new file mode 100644 index 000000000..53428aa8e --- /dev/null +++ b/registry/types/search_result_resp.go @@ -0,0 +1,16 @@ +package types + +import "github.com/alibaba/pouch/apis/types" + +// SearchResultResp response of search images from specific registry +type SearchResultResp struct { + + // NumResults indicates the number of results the query return + NumResults int64 `json:"num_results,omitempty"` + + // query contains the query string that generated the search results + Query string `json:"query,omitempty"` + + // Results is a slice containing the actual results for the search + Results []*types.SearchResultItem `json:"results"` +} diff --git a/test/api_image_search_test.go b/test/api_image_search_test.go index 205fae197..387873926 100644 --- a/test/api_image_search_test.go +++ b/test/api_image_search_test.go @@ -1,7 +1,12 @@ package main import ( + "net/url" + + "github.com/alibaba/pouch/apis/types" "github.com/alibaba/pouch/test/environment" + "github.com/alibaba/pouch/test/request" + "github.com/alibaba/pouch/test/util" "github.com/go-check/check" ) @@ -16,7 +21,26 @@ func init() { // SetUpTest does common setup in the beginning of each test. func (suite *APIImageSearchSuite) SetUpTest(c *check.C) { SkipIfFalse(c, environment.IsLinux) +} + +func (suite *APIImageSearchSuite) TestImageSearchOK(c *check.C) { + q := url.Values{} + q.Add("term", "nginx") + q.Add("registry", "") + + query := request.WithQuery(q) + resp, err := request.Post("/images/search", query) + + defer resp.Body.Close() + CheckRespStatus(c, resp, 200) + c.Assert(err, check.IsNil) + + var got []types.SearchResultItem + request.DecodeBody(&got, resp.Body) - // TODO: missing case - helpwantedForMissingCase(c, "image api search cases") + c.Assert(util.PartialEqual(got[0].Name, "nginx"), check.IsNil) + c.Assert(got[0].Description, check.NotNil) + c.Assert(got[0].IsOfficial, check.NotNil) + c.Assert(got[0].IsAutomated, check.NotNil) + c.Assert(got[0].StarCount, check.NotNil) } diff --git a/test/cli_search_test.go b/test/cli_search_test.go new file mode 100644 index 000000000..2f2369b88 --- /dev/null +++ b/test/cli_search_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "strings" + + "github.com/alibaba/pouch/test/command" + "github.com/alibaba/pouch/test/environment" + "github.com/alibaba/pouch/test/util" + "github.com/gotestyourself/gotestyourself/icmd" + + "github.com/go-check/check" +) + +// PouchSearchSuite is the test suite for search CLI. +type PouchSearchSuite struct{} + +func init() { + check.Suite(&PouchSearchSuite{}) +} + +// SetUpSuite does common setup in the beginning of each test suite. +func (suite *PouchSearchSuite) SetUpSuite(c *check.C) { + SkipIfFalse(c, environment.IsLinux) +} + +// TestSearchWorks tests "pouch search" work. +func (suite *PouchSearchSuite) TestSearchWorks(c *check.C) { + res := command.PouchRun("search", "nginx") + res.Assert(c, icmd.Success) + if !strings.Contains(res.String(), "nginx") { + c.Fatalf("the search result should contain nginx") + } + + resSpecificRegistry := command.PouchRun("search", "-r", "https://index.docker.io/v1/", "nginx") + resSpecificRegistry.Assert(c, icmd.Success) + if !strings.Contains(res.String(), "nginx") { + c.Fatalf("the search result should contain nginx") + } + + resWrongRegistry := command.PouchRun("search", "-r", "index.docker.io", "nginx") + err := util.PartialEqual(resWrongRegistry.Stderr(), "unsupported protocol scheme") + c.Assert(err, check.IsNil) +}