diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 375fc2d45b7..f8ab56799bd 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -44,6 +44,7 @@ import ( "github.com/containerd/nerdctl/v2/cmd/nerdctl/manifest" "github.com/containerd/nerdctl/v2/cmd/nerdctl/namespace" "github.com/containerd/nerdctl/v2/cmd/nerdctl/network" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/search" "github.com/containerd/nerdctl/v2/cmd/nerdctl/system" "github.com/containerd/nerdctl/v2/cmd/nerdctl/volume" "github.com/containerd/nerdctl/v2/pkg/config" @@ -309,6 +310,7 @@ Config file ($NERDCTL_TOML): %s image.TagCommand(), image.RmiCommand(), image.HistoryCommand(), + search.Command(), // #endregion // #region System diff --git a/cmd/nerdctl/search/search.go b/cmd/nerdctl/search/search.go new file mode 100644 index 00000000000..eb1b652d35d --- /dev/null +++ b/cmd/nerdctl/search/search.go @@ -0,0 +1,86 @@ +/* + Copyright The containerd 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 search + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/search" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "search [OPTIONS] TERM", + Short: "Search registry for images", + Args: cobra.ExactArgs(1), + RunE: runSearch, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + + flags.Bool("no-trunc", false, "Don't truncate output") + flags.StringSliceP("filter", "f", nil, "Filter output based on conditions provided") + flags.Int("limit", 0, "Max number of search results") + flags.String("format", "", "Pretty-print search using a Go template") + + return cmd +} + +func processSearchFlags(cmd *cobra.Command) (types.SearchOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.SearchOptions{}, err + } + + noTrunc, err := cmd.Flags().GetBool("no-trunc") + if err != nil { + return types.SearchOptions{}, err + } + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return types.SearchOptions{}, err + } + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.SearchOptions{}, err + } + filter, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.SearchOptions{}, err + } + + return types.SearchOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + NoTrunc: noTrunc, + Limit: limit, + Filters: filter, + Format: format, + }, nil +} + +func runSearch(cmd *cobra.Command, args []string) error { + options, err := processSearchFlags(cmd) + if err != nil { + return err + } + + return search.Search(cmd.Context(), args[0], options) +} diff --git a/cmd/nerdctl/search/search_linux_test.go b/cmd/nerdctl/search/search_linux_test.go new file mode 100644 index 00000000000..76c318b7f38 --- /dev/null +++ b/cmd/nerdctl/search/search_linux_test.go @@ -0,0 +1,241 @@ +/* + Copyright The containerd 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 search + +import ( + "errors" + "regexp" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// All tests in this file are based on the output of `nerdctl search alpine`. +// +// Expected output format (default behavior with --limit 10): +// +// NAME DESCRIPTION STARS OFFICIAL +// alpine A minimal Docker image based on Alpine Linux… 11437 [OK] +// alpine/git A simple git container running in alpine li… 249 +// alpine/socat Run socat command in alpine container 115 +// alpine/helm Auto-trigger docker build for kubernetes hel… 69 +// alpine/curl 11 +// alpine/k8s Kubernetes toolbox for EKS (kubectl, helm, i… 64 +// alpine/bombardier Auto-trigger docker build for bombardier whe… 28 +// alpine/httpie Auto-trigger docker build for `httpie` when … 21 +// alpine/terragrunt Auto-trigger docker build for terragrunt whe… 18 +// alpine/openssl openssl 7 + +func TestSearch(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "basic-search", + Command: test.Command("search", "alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("STARS"), + expect.Contains("OFFICIAL"), + expect.Match(regexp.MustCompile(`NAME\s+DESCRIPTION\s+STARS\s+OFFICIAL`)), + expect.Contains("alpine"), + expect.Match(regexp.MustCompile(`alpine\s+A minimal Docker image based on Alpine Linux`)), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + expect.Contains("[OK]"), + expect.Match(regexp.MustCompile(`alpine/\w+`)), + ), + } + }, + }, + { + Description: "search-library-image", + Command: test.Command("search", "library/alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("STARS"), + expect.Contains("OFFICIAL"), + expect.Contains("alpine"), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + ), + } + }, + }, + { + Description: "search-with-no-trunc", + Command: test.Command("search", "alpine", "--limit", "3", "--no-trunc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("alpine"), + // With --no-trunc, the full description should be visible (not truncated with …) + expect.Match(regexp.MustCompile(`alpine\s+A minimal Docker image based on Alpine Linux with a complete package index and only 5 MB in size!`)), + ), + } + }, + }, + { + Description: "search-with-format", + Command: test.Command("search", "alpine", "--limit", "2", "--format", "{{.Name}}: {{.StarCount}}"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`alpine:\s*\d+`)), + expect.DoesNotContain("NAME"), + expect.DoesNotContain("DESCRIPTION"), + expect.DoesNotContain("OFFICIAL"), + ), + } + }, + }, + { + Description: "search-output-format", + Command: test.Command("search", "alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`NAME\s+DESCRIPTION\s+STARS\s+OFFICIAL`)), + expect.Match(regexp.MustCompile(`(?m)^alpine\s+.*\s+\d+\s+\[OK\]\s*$`)), + expect.Match(regexp.MustCompile(`(?m)^alpine/\w+\s+.*\s+\d+\s*$`)), + expect.DoesNotMatch(regexp.MustCompile(`(?m)^\s+\d+\s*$`)), + ), + } + }, + }, + { + Description: "search-description-formatting", + Command: test.Command("search", "alpine", "--limit", "10"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`Alpine Linux…`)), + expect.DoesNotMatch(regexp.MustCompile(`(?m)^\s+\d+\s+`)), + expect.Match(regexp.MustCompile(`(?m)^[a-z0-9/_-]+\s+.*\s+\d+`)), + ), + } + }, + }, + } + + testCase.Run(t) +} + +func TestSearchWithFilter(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "filter-is-official-true", + Command: test.Command("search", "alpine", "--filter", "is-official=true", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("OFFICIAL"), + expect.Contains("alpine"), + expect.Contains("[OK]"), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + ), + } + }, + }, + { + Description: "filter-stars", + Command: test.Command("search", "alpine", "--filter", "stars=10000"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("STARS"), + expect.Contains("alpine"), + // The official alpine image has > 10000 stars + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d{4,}\s+\[OK\]`)), + ), + } + }, + }, + } + + testCase.Run(t) +} + +func TestSearchFilterErrors(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "invalid-filter-format", + Command: test.Command("search", "alpine", "--filter", "foo"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("bad format of filter (expected name=value)")}, + } + }, + }, + { + Description: "invalid-filter-key", + Command: test.Command("search", "alpine", "--filter", "foo=bar"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'foo'")}, + } + }, + }, + { + Description: "invalid-stars-value", + Command: test.Command("search", "alpine", "--filter", "stars=abc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'stars=abc'")}, + } + }, + }, + { + Description: "invalid-is-official-value", + Command: test.Command("search", "alpine", "--filter", "is-official=abc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'is-official=abc'")}, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/search/search_test.go b/cmd/nerdctl/search/search_test.go new file mode 100644 index 00000000000..a76005fb94f --- /dev/null +++ b/cmd/nerdctl/search/search_test.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd 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 search + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 3f9f415051d..e0d7b41f895 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -66,6 +66,7 @@ - [Registry](#registry) - [:whale: nerdctl login](#whale-nerdctl-login) - [:whale: nerdctl logout](#whale-nerdctl-logout) + - [:whale: nerdctl search](#whale-nerdctl-search) - [Network management](#network-management) - [:whale: nerdctl network create](#whale-nerdctl-network-create) - [:whale: nerdctl network ls](#whale-nerdctl-network-ls) @@ -1209,6 +1210,19 @@ Log out from a container registry Usage: `nerdctl logout [SERVER]` +### :whale: nerdctl search + +Search Docker Hub or a registry for images + +Usage: `nerdctl search [OPTIONS] TERM` + +Flags: + +- :whale: `--limit`: Max number of search results (default: 0) +- :whale: `--no-trunc`: Don't truncate output (default: false) +- :whale: `--filter, -f`: Filter output based on conditions provided +- :whale: `--format`: Format the output using the given Go template + ## Network management ### :whale: nerdctl network create @@ -1978,10 +1992,6 @@ Network management: - `docker network connect` - `docker network disconnect` -Registry: - -- `docker search` - Compose: - `docker-compose events|scale` diff --git a/pkg/api/types/search_types.go b/pkg/api/types/search_types.go new file mode 100644 index 00000000000..645335a72c3 --- /dev/null +++ b/pkg/api/types/search_types.go @@ -0,0 +1,36 @@ +/* + Copyright The containerd 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 types + +import ( + "io" +) + +type SearchOptions struct { + Stdout io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions + + // NoTrunc don't truncate output + NoTrunc bool + // Limit the number of results + Limit int + // Filter output based on conditions provided, for the --filter argument + Filters []string + // Format the output using the given Go template, e.g, '{{json .}}' + Format string +} diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go new file mode 100644 index 00000000000..e0db9206ddc --- /dev/null +++ b/pkg/cmd/search/search.go @@ -0,0 +1,271 @@ +/* + Copyright The containerd 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 search + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "text/tabwriter" + + dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +type SearchResult struct { + Description string `json:"description"` + IsOfficial bool `json:"is_official"` + Name string `json:"name"` + StarCount int `json:"star_count"` +} + +func Search(ctx context.Context, term string, options types.SearchOptions) error { + // Validate filters before making HTTP request + filterMap, err := validateAndParseFilters(options.Filters) + if err != nil { + return err + } + + registryHost, searchTerm := splitReposSearchTerm(term) + + parsedRef, err := referenceutil.Parse(registryHost) + if err != nil { + log.G(ctx).WithError(err).Debugf("failed to parse registry host %q, using as-is", registryHost) + } else { + registryHost = parsedRef.Domain + } + + var dOpts []dockerconfigresolver.Opt + + if options.GOptions.InsecureRegistry { + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", registryHost) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) + + hostOpts, err := dockerconfigresolver.NewHostOptions(ctx, registryHost, dOpts...) + if err != nil { + return fmt.Errorf("failed to create host options: %w", err) + } + + username, password, err := hostOpts.Credentials(registryHost) + if err != nil { + log.G(ctx).WithError(err).Debug("no credentials found, searching anonymously") + } + + scheme := "https" + if hostOpts.DefaultScheme != "" { + scheme = hostOpts.DefaultScheme + } + + searchURL := buildSearchURL(registryHost, searchTerm, scheme) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return err + } + + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } + + client := createHTTPClient(hostOpts) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("search failed with status %d: %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + Results []SearchResult `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return fmt.Errorf("failed to decode search response: %w", err) + } + + filteredResults := applyFilters(searchResp.Results, filterMap, options.Limit) + + return printSearchResults(options.Stdout, filteredResults, options) +} + +func splitReposSearchTerm(reposName string) (registryHost string, searchTerm string) { + nameParts := strings.SplitN(reposName, "/", 2) + if len(nameParts) == 1 || + (!strings.Contains(nameParts[0], ".") && + !strings.Contains(nameParts[0], ":") && + nameParts[0] != "localhost") { + // No registry specified, use docker.io + // For "library/alpine", the search term should be "alpine" + // For "alpine", the search term should be "alpine" + if len(nameParts) == 2 && nameParts[0] == "library" { + return "docker.io", nameParts[1] + } + return "docker.io", reposName + } + return nameParts[0], nameParts[1] +} + +func buildSearchURL(registryHost, term, scheme string) string { + host := registryHost + if host == "docker.io" { + host = "index.docker.io" + } + + u := url.URL{ + Scheme: scheme, + Host: host, + Path: "/v1/search", + } + q := u.Query() + q.Set("q", term) + u.RawQuery = q.Encode() + + return u.String() +} + +func createHTTPClient(hostOpts *dockerconfig.HostOptions) *http.Client { + if hostOpts != nil && hostOpts.DefaultTLS != nil { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: hostOpts.DefaultTLS, + }, + } + } + return http.DefaultClient +} + +func validateFilterValue(key, value string) error { + switch key { + case "stars": + if _, err := strconv.Atoi(value); err != nil { + return fmt.Errorf("invalid filter 'stars=%s'", value) + } + case "is-official": + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("invalid filter 'is-official=%s'", value) + } + default: + return fmt.Errorf("invalid filter '%s'", key) + } + return nil +} + +// validateAndParseFilters validates and parses filters before making HTTP request +func validateAndParseFilters(filters []string) (map[string]string, error) { + filterMap := make(map[string]string) + for _, f := range filters { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("bad format of filter (expected name=value)") + } + key := parts[0] + value := parts[1] + if err := validateFilterValue(key, value); err != nil { + return nil, err + } + filterMap[key] = value + } + return filterMap, nil +} + +func applyFilters(results []SearchResult, filterMap map[string]string, limit int) []SearchResult { + filtered := make([]SearchResult, 0, len(results)) + + for _, r := range results { + if val, ok := filterMap["is-official"]; ok { + b, _ := strconv.ParseBool(val) + if b != r.IsOfficial { + continue + } + } + + if val, ok := filterMap["stars"]; ok { + stars, _ := strconv.Atoi(val) + if r.StarCount < stars { + continue + } + } + + filtered = append(filtered, r) + } + + // Apply limit after filtering, but maintain original order from API + if limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + + return filtered +} + +func truncateDescription(desc string, noTrunc bool) string { + if !noTrunc && len(desc) > 45 { + return formatter.Ellipsis(desc, 45) + } + return desc +} + +func printSearchResults(stdout io.Writer, results []SearchResult, options types.SearchOptions) error { + for i := range results { + results[i].Description = truncateDescription(results[i].Description, options.NoTrunc) + } + + if options.Format != "" { + tmpl, err := formatter.ParseTemplate(options.Format) + if err != nil { + return err + } + for _, r := range results { + if err := tmpl.Execute(stdout, r); err != nil { + return err + } + fmt.Fprintln(stdout) + } + return nil + } + + w := tabwriter.NewWriter(stdout, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tSTARS\tOFFICIAL") + + for _, r := range results { + desc := strings.ReplaceAll(r.Description, "\n", " ") + desc = strings.ReplaceAll(desc, "\t", " ") + + official := "" + if r.IsOfficial { + official = "[OK]" + } + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", r.Name, desc, r.StarCount, official) + } + return w.Flush() +}