Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/nerdctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -309,6 +310,7 @@ Config file ($NERDCTL_TOML): %s
image.TagCommand(),
image.RmiCommand(),
image.HistoryCommand(),
search.Command(),
// #endregion

// #region System
Expand Down
86 changes: 86 additions & 0 deletions cmd/nerdctl/search/search.go
Original file line number Diff line number Diff line change
@@ -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)
}
241 changes: 241 additions & 0 deletions cmd/nerdctl/search/search_linux_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
27 changes: 27 additions & 0 deletions cmd/nerdctl/search/search_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading