From 626b686259b9e26fcf3ca3d383b2acc2ba0feb91 Mon Sep 17 00:00:00 2001 From: ChengyuZhu6 Date: Wed, 17 Dec 2025 22:46:49 +0800 Subject: [PATCH] support nerdctl search command support nerdctl search command Signed-off-by: ChengyuZhu6 --- cmd/nerdctl/main.go | 2 + cmd/nerdctl/search/search.go | 86 +++++++++ cmd/nerdctl/search/search_linux_test.go | 142 +++++++++++++++ cmd/nerdctl/search/search_test.go | 27 +++ docs/command-reference.md | 18 +- pkg/api/types/search_types.go | 36 ++++ pkg/cmd/search/search.go | 226 ++++++++++++++++++++++++ 7 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 cmd/nerdctl/search/search.go create mode 100644 cmd/nerdctl/search/search_linux_test.go create mode 100644 cmd/nerdctl/search/search_test.go create mode 100644 pkg/api/types/search_types.go create mode 100644 pkg/cmd/search/search.go 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..a18a52e936f --- /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.StringSlice("filter", 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..c40dd669aee --- /dev/null +++ b/cmd/nerdctl/search/search_linux_test.go @@ -0,0 +1,142 @@ +/* + 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 ( + "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.Command = test.Command("search", "alpine", "--limit", "5") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, 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+`)), + )) + + testCase.Run(t) +} + +func TestSearchWithFilter(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = test.Command("search", "alpine", "--filter", "is-official=true", "--limit", "5") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("NAME"), + expect.Contains("OFFICIAL"), + expect.Contains("alpine"), + expect.Contains("[OK]"), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + )) + + testCase.Run(t) +} + +func TestSearchWithNoTrunc(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = test.Command("search", "alpine", "--limit", "3", "--no-trunc") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("alpine"), + // With --no-trunc, the full description should be visible (not truncated with …) + // The alpine description is longer than 45 chars, so without truncation + // we should see more complete text + 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!`)), + )) + + testCase.Run(t) +} + +func TestSearchWithFormat(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = test.Command("search", "alpine", "--limit", "2", "--format", "{{.Name}}: {{.StarCount}}") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Match(regexp.MustCompile(`alpine:\s*\d+`)), + expect.DoesNotContain("NAME"), + expect.DoesNotContain("DESCRIPTION"), + expect.DoesNotContain("OFFICIAL"), + )) + + testCase.Run(t) +} + +func TestSearchOutputFormat(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = test.Command("search", "alpine", "--limit", "5") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, 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*$`)), + )) + + testCase.Run(t) +} + +func TestSearchDescriptionFormatting(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Command = test.Command("search", "alpine", "--limit", "10") + + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, 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) +} 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 6786c757f0c..7a6f6298eac 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`: 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..3e3a2ecc844 --- /dev/null +++ b/pkg/cmd/search/search.go @@ -0,0 +1,226 @@ +/* + 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 { + StarCount int `json:"star_count"` + IsOfficial bool `json:"is_official"` + Name string `json:"name"` + Description string `json:"description"` +} + +func Search(ctx context.Context, term string, options types.SearchOptions) error { + registryHost := 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, term, 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, options) + + return printSearchResults(options.Stdout, filteredResults, options) +} + +func splitReposSearchTerm(reposName string) string { + nameParts := strings.SplitN(reposName, "/", 2) + if len(nameParts) == 1 || + (!strings.Contains(nameParts[0], ".") && + !strings.Contains(nameParts[0], ":") && + nameParts[0] != "localhost") { + return "docker.io" + } + return nameParts[0] +} + +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 applyFilters(results []SearchResult, options types.SearchOptions) []SearchResult { + filtered := make([]SearchResult, 0, len(results)) + filterMap := make(map[string]string) + for _, f := range options.Filters { + parts := strings.SplitN(f, "=", 2) + if len(parts) == 2 { + filterMap[parts[0]] = parts[1] + } else { + filterMap[parts[0]] = "true" + } + } + + for _, r := range results { + if options.Limit > 0 && len(filtered) >= options.Limit { + break + } + + if val, ok := filterMap["is-official"]; ok { + if b, _ := strconv.ParseBool(val); b != r.IsOfficial { + continue + } + } + + if val, ok := filterMap["stars"]; ok { + stars, _ := strconv.Atoi(val) + if r.StarCount < stars { + continue + } + } + + filtered = append(filtered, r) + } + + return filtered +} + +func printSearchResults(stdout io.Writer, results []SearchResult, options types.SearchOptions) error { + 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 := r.Description + if !options.NoTrunc && len(desc) > 45 { + desc = formatter.Ellipsis(desc, 45) + } + + desc = strings.ReplaceAll(desc, "\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() +}