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
27 changes: 1 addition & 26 deletions internal/fnruntime/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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})
}
}

Expand Down
4 changes: 0 additions & 4 deletions internal/fnruntime/container_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
//go:build docker

// Copyright 2021,2026 The kpt Authors
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The //go:build docker build tag has been removed from this test file. The TestContainerFn test attempts to pull and run actual Docker containers (line 38-39), which will fail on systems without Docker installed. This removal may cause test suite failures on such systems. Consider restoring the build tag to ensure the test only runs in Docker-capable environments, or refactor the test to use mocks instead of real containers.

Copilot uses AI. Check for mistakes.
//
// Licensed under the Apache License, Version 2.0 (the "License");
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions internal/fnruntime/container_utils.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions internal/fnruntime/container_utils_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
27 changes: 21 additions & 6 deletions internal/fnruntime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 24 additions & 12 deletions internal/fnruntime/tag_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading