diff --git a/internal/fnruntime/container.go b/internal/fnruntime/container.go index cd675e0d7a..bbb66eb2e7 100644 --- a/internal/fnruntime/container.go +++ b/internal/fnruntime/container.go @@ -31,8 +31,6 @@ import ( fnresult "github.com/kptdev/kpt/pkg/api/fnresult/v1" "github.com/kptdev/kpt/pkg/lib/runneroptions" "github.com/kptdev/kpt/pkg/printer" - "github.com/regclient/regclient" - regclientref "github.com/regclient/regclient/types/ref" "golang.org/x/mod/semver" "sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil" ) @@ -100,29 +98,6 @@ type ContainerFn struct { FnResult *fnresult.Result } -// RegClientLister is a TagLister using the regclient module to list remote OCI tags. -type RegClientLister struct { - client *regclient.RegClient -} - -var _ TagLister = &RegClientLister{} - -func (l *RegClientLister) List(ctx context.Context, image string) ([]string, error) { - ref, err := regclientref.New(image) - if err != nil { - return nil, err - } - - defer func() { _ = l.client.Close(ctx, ref) }() - - tagList, err := l.client.TagList(ctx, ref) - if err != nil { - return nil, err - } - - return tagList.GetTags() -} - func (r ContainerRuntime) GetBin() string { switch r { case Podman: @@ -403,7 +378,7 @@ func StringToContainerRuntime(v string) (ContainerRuntime, error) { case "": return Docker, nil default: - return "", fmt.Errorf("unsupported runtime: %q the runtime must be either %s or %s", v, Docker, Podman) + return "", fmt.Errorf("unsupported runtime: %q the runtime must be one of %v", v, []ContainerRuntime{Docker, Podman, Nerdctl}) } } diff --git a/internal/fnruntime/container_test.go b/internal/fnruntime/container_test.go index e512fdb1ab..48e06cf4de 100644 --- a/internal/fnruntime/container_test.go +++ b/internal/fnruntime/container_test.go @@ -1,5 +1,3 @@ -//go:build docker - // Copyright 2021,2026 The kpt Authors // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -48,7 +46,6 @@ func TestContainerFn(t *testing.T) { } for _, tt := range tests { - tt := tt ctx := context.Background() t.Run(tt.name, func(t *testing.T) { errBuff := &bytes.Buffer{} @@ -101,7 +98,6 @@ func TestIsSupportedDockerVersion(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { require := require.New(t) err := isSupportedDockerVersion(tt.inputV) diff --git a/internal/fnruntime/container_utils.go b/internal/fnruntime/container_utils.go new file mode 100644 index 0000000000..21026fbb68 --- /dev/null +++ b/internal/fnruntime/container_utils.go @@ -0,0 +1,79 @@ +package fnruntime + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/regclient/regclient" + regclientref "github.com/regclient/regclient/types/ref" +) + +// RegClientLister is a TagLister using the regclient module to list remote OCI tags. +type RegClientLister struct { + client *regclient.RegClient +} + +var _ TagLister = &RegClientLister{} + +func (l *RegClientLister) Name() string { + return "regclient" +} + +func (l *RegClientLister) List(ctx context.Context, image string) ([]string, error) { + ref, err := regclientref.New(image) + if err != nil { + return nil, err + } + + defer func() { _ = l.client.Close(ctx, ref) }() + + tagList, err := l.client.TagList(ctx, ref) + if err != nil { + return nil, err + } + + return tagList.GetTags() +} + +// LocalLister is a TagLister using the given CLI tool to list local OCI tags +type LocalLister struct { + Binary string +} + +var _ TagLister = &LocalLister{} + +func (l *LocalLister) Name() string { + return "local-" + l.Binary +} + +func (l *LocalLister) List(ctx context.Context, image string) ([]string, error) { + command := exec.CommandContext(ctx, l.Binary, "image", "ls", "--filter", "reference="+image, "--format", "{{ .Tag }}") + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + command.Stdout = stdout + command.Stderr = stderr + + if err := command.Run(); err != nil { + return nil, fmt.Errorf("failed to list local tags using %q: %w; stderr: %s", l.Binary, err, stderr.String()) + } + + return linesToSlice(stdout.String()), nil +} + +func linesToSlice(in string) []string { + in = strings.TrimSpace(in) + in = strings.ReplaceAll(in, "\r\n", "\n") + var out []string + for line := range strings.Lines(in) { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + + return out +} diff --git a/internal/fnruntime/container_utils_test.go b/internal/fnruntime/container_utils_test.go new file mode 100644 index 0000000000..2c220d2f9e --- /dev/null +++ b/internal/fnruntime/container_utils_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 The kpt 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 fnruntime + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLinesToSlice(t *testing.T) { + tests := map[string]struct { + input string + expected []string + }{ + "empty": { + input: "", + expected: nil, + }, + "whitespace only": { + input: " \t\n ", + expected: nil, + }, + "single line": { + input: "v1.0.0", + expected: []string{"v1.0.0"}, + }, + "single line with surrounding space": { + input: " v1.0.0 \n", + expected: []string{"v1.0.0"}, + }, + "multiple lines": { + input: "v1.0.0\nv1.0.1\nv1.0.2", + expected: []string{"v1.0.0", "v1.0.1", "v1.0.2"}, + }, + "windows line endings": { + input: "a\r\nb\r\nc", + expected: []string{"a", "b", "c"}, + }, + "blank line not preserved": { + input: "a\n\nb", + expected: []string{"a", "b"}, + }, + "per-line trim": { + input: " foo \n bar ", + expected: []string{"foo", "bar"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := linesToSlice(tc.input) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestRegClientLister_Name(t *testing.T) { + l := &RegClientLister{} + assert.Equal(t, "regclient", l.Name()) +} + +func TestLocalLister_Name(t *testing.T) { + tests := map[string]struct { + binary string + expected string + }{ + "docker": { + binary: "docker", + expected: "local-docker", + }, + "empty binary": { + binary: "", + expected: "local-", + }, + "path-like binary": { + binary: "/usr/local/bin/nerdctl", + expected: "local-/usr/local/bin/nerdctl", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + l := &LocalLister{Binary: tc.binary} + assert.Equal(t, tc.expected, l.Name()) + }) + } +} diff --git a/internal/fnruntime/runner.go b/internal/fnruntime/runner.go index 035e8ad701..3cef092b58 100644 --- a/internal/fnruntime/runner.go +++ b/internal/fnruntime/runner.go @@ -62,14 +62,29 @@ func NewRunner( img := opts.ResolveToImage(f.Image) f.Image = img + listers := []TagLister{ + &RegClientLister{ + client: regclient.New( + regclient.WithUserAgent(UserAgent), + regclient.WithDockerCreds(), + ), + }, + } + + if containerRuntime, err := StringToContainerRuntime(os.Getenv(ContainerRuntimeEnv)); err == nil { + listers = append( + []TagLister{ + &LocalLister{ + Binary: containerRuntime.GetBin(), + }, + }, + listers..., + ) + } + if f.Tag != "" { tagResolver := &TagResolver{ - lister: &RegClientLister{ - client: regclient.New( - regclient.WithUserAgent(UserAgent), - regclient.WithDockerCreds(), - ), - }, + Listers: listers, } f.Image, err = tagResolver.ResolveFunctionImage(ctx, f.Image, f.Tag) if err != nil { diff --git a/internal/fnruntime/tag_resolution.go b/internal/fnruntime/tag_resolution.go index a1b09270b7..8b96c38650 100644 --- a/internal/fnruntime/tag_resolution.go +++ b/internal/fnruntime/tag_resolution.go @@ -27,11 +27,13 @@ import ( // TagLister is an interface for listing tags for/from a function runtime/runner type TagLister interface { + Name() string List(ctx context.Context, image string) ([]string, error) } type TagResolver struct { - lister TagLister + // Listers is a slice of TagListers that are checked in order for a matching tag. + Listers []TagLister } // ResolveFunctionImage substitutes the `function.image` with the latest tag matching the constraint in `function.tag`. @@ -53,20 +55,30 @@ func (tr *TagResolver) ResolveFunctionImage(ctx context.Context, image, tag stri // A valid version is a valid constraint, but we don't want to waste time listing // when we are given an exact version. We just return from here. } else if constraint, constraintErr := semver.NewConstraint(tag); constraintErr == nil { - possibleTags, err := tr.lister.List(ctx, image) - if err != nil { - return "", fmt.Errorf("failed to list tags for image %q: %w", image, err) - } + for _, lister := range tr.Listers { + possibleTags, err := lister.List(ctx, image) + if err != nil { + klog.V(2).Infof("failed to list tags for image %q using lister %q: %v", image, lister.Name(), err) + continue + } - filteredVersions := filterParseSortTags(possibleTags) - for _, version := range filteredVersions { - if constraint.Check(version) { - ref.Tag = version.Original() - return ref.CommonName(), nil + if len(possibleTags) == 0 { + klog.V(2).Infof("no tags found for image %q with lister %q", image, lister.Name()) + continue } + + filteredVersions := filterParseSortTags(possibleTags) + for _, version := range filteredVersions { + if constraint.Check(version) { + ref.Tag = version.Original() + return ref.CommonName(), nil + } + } + + klog.Infof("no tag matched the version constraint %q when using lister %q (from %s)", tag, lister.Name(), abbrevSlice(filteredVersions)) } - return "", fmt.Errorf("no remote tag matched the version constraint %q from %s", tag, abbrevSlice(filteredVersions)) + return "", fmt.Errorf("no tag could be found matching the version constraint %q", tag) } else { klog.Warningf("Tag %q could not be parsed as a semantic version (\"%s\") or constraint (\"%s\"), will use it literally", tag, versionErr, constraintErr) @@ -115,7 +127,7 @@ func abbrevSlice(slice []*semver.Version) string { for i, v := range slice { out[i] = v.Original() } - return "[" + strings.Join(out, ",") + "]" + return "[" + strings.Join(out, ", ") + "]" default: return fmt.Sprintf("[%s, %s, ..., %s]", slice[0].Original(), slice[1].Original(), slice[len(slice)-1].Original()) diff --git a/internal/fnruntime/tag_resolution_test.go b/internal/fnruntime/tag_resolution_test.go index 1bd3294502..6eb9a6b6bd 100644 --- a/internal/fnruntime/tag_resolution_test.go +++ b/internal/fnruntime/tag_resolution_test.go @@ -17,6 +17,7 @@ package fnruntime import ( "context" "errors" + "fmt" "testing" "github.com/Masterminds/semver/v3" @@ -29,6 +30,8 @@ type fakeLister struct { err string } +var _ TagLister = &fakeLister{} + func (frc *fakeLister) List(_ context.Context, image string) ([]string, error) { if frc.err != "" { return nil, errors.New(frc.err) @@ -46,7 +49,9 @@ func (frc *fakeLister) List(_ context.Context, image string) ([]string, error) { return tags, nil } -var _ TagLister = &fakeLister{} +func (frc *fakeLister) Name() string { + return "fake" +} func TestFilterParseSortTags(t *testing.T) { testCases := map[string]struct { @@ -126,7 +131,7 @@ func TestResolveFunctionImage(t *testing.T) { functionImage: image, functionTag: "0.3.x", repoTags: tagSet, - expectedErr: "no remote tag matched the version constraint", + expectedErr: "no tag could be found matching the version constraint", }, "image preserved on empty tag": { functionImage: image + ":v0.3.1", @@ -161,7 +166,7 @@ func TestResolveFunctionImage(t *testing.T) { functionImage: image, functionTag: "~0.1", repoErr: "test", - expectedErr: "failed to list tags for image", + expectedErr: "no tag could be found matching the version constraint", }, "digest replaced correctly": { functionImage: image + "@sha256:59a5a43c8fcafaf14b5fd4463ccb3fda61d6c0b55ff218cbb5783a29c8d6c20c", @@ -169,15 +174,29 @@ func TestResolveFunctionImage(t *testing.T) { repoTags: tagSet, expectedTag: "v0.1.2", }, + // this case is technically impossible in a live scenario + "image exist but has no tags": { + functionImage: image, + functionTag: "~0.1", + repoTags: []string{}, + expectedErr: "no tag could be found matching the version constraint", + }, + "invalid image ref": { + functionImage: "aaaaa~~234..=/", + functionTag: "~0.1", + expectedErr: "failed to parse image \"aaaaa~~234..=/\" as reference", + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { tr := &TagResolver{ - lister: &fakeLister{ - err: tc.repoErr, - tags: map[string][]string{ - image: tc.repoTags, + Listers: []TagLister{ + &fakeLister{ + err: tc.repoErr, + tags: map[string][]string{ + image: tc.repoTags, + }, }, }, } @@ -191,3 +210,48 @@ func TestResolveFunctionImage(t *testing.T) { }) } } + +func TestAbbrevSlice(t *testing.T) { + tagSet := []string{"v0.1", "v0.2.3", "v0.1.2", "v0", "v0.2", "v0.1.1", "v0.2.1", "v0.2.2"} + var versions []*semver.Version + for _, tag := range tagSet { + versions = append(versions, semver.MustParse(tag)) + } + + testCases := []struct { + input []*semver.Version + expected string + }{ + { + input: []*semver.Version{}, + expected: "[]", + }, + { + input: versions[:1], + expected: "[v0.1]", + }, + { + input: versions[:2], + expected: "[v0.1, v0.2.3]", + }, + { + input: versions[:3], + expected: "[v0.1, v0.2.3, v0.1.2]", + }, + { + input: versions[:4], + expected: "[v0.1, v0.2.3, ..., v0]", + }, + { + input: versions, + expected: "[v0.1, v0.2.3, ..., v0.2.2]", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("len %d", len(tc.input)), func(t *testing.T) { + result := abbrevSlice(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}