github.com/GoogleContainerTools/kpt@v1.0.0-beta.50.0.20240520170205-c25345ffcbee/internal/fnruntime/container.go (about)

     1  // Copyright 2021 The kpt Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package fnruntime
    16  
    17  import (
    18  	"bufio"
    19  	"bytes"
    20  	"context"
    21  	goerrors "errors"
    22  	"fmt"
    23  	"io"
    24  	"os"
    25  	"os/exec"
    26  	"regexp"
    27  	"strings"
    28  	"sync"
    29  	"time"
    30  
    31  	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
    32  	"github.com/GoogleContainerTools/kpt/pkg/printer"
    33  	"golang.org/x/mod/semver"
    34  	"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
    35  )
    36  
    37  // We may create multiple instance of ContainerFn, but we only want to check
    38  // if container runtime is available once.
    39  var checkContainerRuntimeOnce sync.Once
    40  
    41  // containerNetworkName is a type for network name used in container
    42  type containerNetworkName string
    43  
    44  const (
    45  	networkNameNone           containerNetworkName = "none"
    46  	networkNameHost           containerNetworkName = "host"
    47  	defaultLongTimeout                             = 5 * time.Minute
    48  	versionCommandTimeout                          = 5 * time.Second
    49  	minSupportedDockerVersion string               = "v20.10.0"
    50  
    51  	dockerBin  string = "docker"
    52  	podmanBin  string = "podman"
    53  	nerdctlBin string = "nerdctl"
    54  
    55  	ContainerRuntimeEnv = "KPT_FN_RUNTIME"
    56  
    57  	Docker  ContainerRuntime = "docker"
    58  	Podman  ContainerRuntime = "podman"
    59  	Nerdctl ContainerRuntime = "nerdctl"
    60  )
    61  
    62  type ContainerRuntime string
    63  
    64  // ContainerFnPermission contains the permission of container
    65  // function such as network access.
    66  type ContainerFnPermission struct {
    67  	AllowNetwork bool
    68  	AllowMount   bool
    69  }
    70  
    71  // ContainerFn implements a KRMFn which run a containerized
    72  // KRM function
    73  type ContainerFn struct {
    74  	Ctx context.Context
    75  
    76  	// Image is the container image to run
    77  	Image string
    78  	// ImagePullPolicy controls the image pulling behavior.
    79  	ImagePullPolicy ImagePullPolicy
    80  	// Container function will be killed after this timeour.
    81  	// The default value is 5 minutes.
    82  	Timeout time.Duration
    83  	Perm    ContainerFnPermission
    84  	// UIDGID is the os User ID and Group ID that will be
    85  	// used to run the container in format userId:groupId.
    86  	// If it's empty, "nobody" will be used.
    87  	UIDGID string
    88  	// StorageMounts are the storage or directories to mount
    89  	// into the container
    90  	StorageMounts []runtimeutil.StorageMount
    91  	// Env is a slice of env string that will be exposed to container
    92  	Env []string
    93  	// FnResult is used to store the information about the result from
    94  	// the function.
    95  	FnResult *fnresult.Result
    96  }
    97  
    98  func (r ContainerRuntime) GetBin() string {
    99  	switch r {
   100  	case Podman:
   101  		return podmanBin
   102  	case Nerdctl:
   103  		return nerdctlBin
   104  	default:
   105  		return dockerBin
   106  	}
   107  }
   108  
   109  // Run runs the container function using docker runtime.
   110  // It reads the input from the given reader and writes the output
   111  // to the provided writer.
   112  func (f *ContainerFn) Run(reader io.Reader, writer io.Writer) error {
   113  	// If the env var is empty, stringToContainerRuntime defaults it to docker.
   114  	runtime, err := StringToContainerRuntime(os.Getenv(ContainerRuntimeEnv))
   115  	if err != nil {
   116  		return err
   117  	}
   118  
   119  	checkContainerRuntimeOnce.Do(func() {
   120  		err = ContainerRuntimeAvailable(runtime)
   121  	})
   122  	if err != nil {
   123  		return err
   124  	}
   125  
   126  	switch runtime {
   127  	case Podman:
   128  		return f.runCLI(reader, writer, podmanBin, filterPodmanCLIOutput)
   129  	case Nerdctl:
   130  		return f.runCLI(reader, writer, nerdctlBin, filterNerdctlCLIOutput)
   131  	default:
   132  		return f.runCLI(reader, writer, dockerBin, filterDockerCLIOutput)
   133  	}
   134  }
   135  
   136  func (f *ContainerFn) runCLI(reader io.Reader, writer io.Writer, bin string, filterCLIOutputFn func(io.Reader) string) error {
   137  	errSink := bytes.Buffer{}
   138  	cmd, cancel := f.getCmd(bin)
   139  	defer cancel()
   140  	cmd.Stdin = reader
   141  	cmd.Stdout = writer
   142  	cmd.Stderr = &errSink
   143  
   144  	if err := cmd.Run(); err != nil {
   145  		var exitErr *exec.ExitError
   146  		if goerrors.As(err, &exitErr) {
   147  			return &ExecError{
   148  				OriginalErr:    exitErr,
   149  				ExitCode:       exitErr.ExitCode(),
   150  				Stderr:         filterCLIOutputFn(&errSink),
   151  				TruncateOutput: printer.TruncateOutput,
   152  			}
   153  		}
   154  		return fmt.Errorf("unexpected function error: %w", err)
   155  	}
   156  
   157  	if errSink.Len() > 0 {
   158  		f.FnResult.Stderr = filterCLIOutputFn(&errSink)
   159  	}
   160  	return nil
   161  }
   162  
   163  // getCmd assembles a command for docker, podman or nerdctl. The input binName
   164  // is expected to be one of "docker", "podman" and "nerdctl".
   165  func (f *ContainerFn) getCmd(binName string) (*exec.Cmd, context.CancelFunc) {
   166  	network := networkNameNone
   167  	if f.Perm.AllowNetwork {
   168  		network = networkNameHost
   169  	}
   170  	uidgid := "nobody"
   171  	if f.UIDGID != "" {
   172  		uidgid = f.UIDGID
   173  	}
   174  
   175  	args := []string{
   176  		"run", "--rm", "-i",
   177  		"--network", string(network),
   178  		"--user", uidgid,
   179  		"--security-opt=no-new-privileges",
   180  	}
   181  
   182  	switch f.ImagePullPolicy {
   183  	case NeverPull:
   184  		args = append(args, "--pull", "never")
   185  	case AlwaysPull:
   186  		args = append(args, "--pull", "always")
   187  	case IfNotPresentPull:
   188  		args = append(args, "--pull", "missing")
   189  	default:
   190  		args = append(args, "--pull", "missing")
   191  	}
   192  	for _, storageMount := range f.StorageMounts {
   193  		args = append(args, "--mount", storageMount.String())
   194  	}
   195  	args = append(args,
   196  		NewContainerEnvFromStringSlice(f.Env).GetDockerFlags()...)
   197  	args = append(args, f.Image)
   198  	// setup container run timeout
   199  	timeout := defaultLongTimeout
   200  	if f.Timeout != 0 {
   201  		timeout = f.Timeout
   202  	}
   203  	ctx, cancel := context.WithTimeout(context.Background(), timeout)
   204  	return exec.CommandContext(ctx, binName, args...), cancel
   205  }
   206  
   207  // NewContainerEnvFromStringSlice returns a new ContainerEnv pointer with parsing
   208  // input envStr. envStr example: ["foo=bar", "baz"]
   209  // using this instead of runtimeutil.NewContainerEnvFromStringSlice() to avoid
   210  // default envs LOG_TO_STDERR
   211  func NewContainerEnvFromStringSlice(envStr []string) *runtimeutil.ContainerEnv {
   212  	ce := &runtimeutil.ContainerEnv{
   213  		EnvVars: make(map[string]string),
   214  	}
   215  	// default envs
   216  	for _, e := range envStr {
   217  		parts := strings.SplitN(e, "=", 2)
   218  		if len(parts) == 1 {
   219  			ce.AddKey(e)
   220  		} else {
   221  			ce.AddKeyValue(parts[0], parts[1])
   222  		}
   223  	}
   224  	return ce
   225  }
   226  
   227  // ResolveToImageForCLI converts the function short path to the full image url.
   228  // If the function is Catalog function, it adds "gcr.io/kpt-fn/".e.g. set-namespace:v0.1 --> gcr.io/kpt-fn/set-namespace:v0.1
   229  func ResolveToImageForCLI(_ context.Context, image string) (string, error) {
   230  	if !strings.Contains(image, "/") {
   231  		return fmt.Sprintf("gcr.io/kpt-fn/%s", image), nil
   232  	}
   233  	return image, nil
   234  }
   235  
   236  // ContainerImageError is an error type which will be returned when
   237  // the container run time cannot verify docker image.
   238  type ContainerImageError struct {
   239  	Image  string
   240  	Output string
   241  }
   242  
   243  func (e *ContainerImageError) Error() string {
   244  	//nolint:lll
   245  	return fmt.Sprintf(
   246  		"Error: Function image %q doesn't exist remotely. If you are developing new functions locally, you can choose to set the image pull policy to ifNotPresent or never.\n%v",
   247  		e.Image, e.Output)
   248  }
   249  
   250  // filterDockerCLIOutput filters out docker CLI messages
   251  // from the given buffer.
   252  func filterDockerCLIOutput(in io.Reader) string {
   253  	s := bufio.NewScanner(in)
   254  	var lines []string
   255  
   256  	for s.Scan() {
   257  		txt := s.Text()
   258  		if !isdockerCLIoutput(txt) {
   259  			lines = append(lines, txt)
   260  		}
   261  	}
   262  	return strings.Join(lines, "\n")
   263  }
   264  
   265  // isdockerCLIoutput is helper method to determine if
   266  // the given string is a docker CLI output message.
   267  // Example docker output:
   268  //
   269  //	"Unable to find image 'gcr.io/kpt-fn/starlark:v0.3' locally"
   270  //	"v0.3: Pulling from kpt-fn/starlark"
   271  //	"4e9f2cdf4387: Already exists"
   272  //	"aafbf7df3ddf: Pulling fs layer"
   273  //	"aafbf7df3ddf: Verifying Checksum"
   274  //	"aafbf7df3ddf: Download complete"
   275  //	"6b759ab96cb2: Waiting"
   276  //	"aafbf7df3ddf: Pull complete"
   277  //	"Digest: sha256:c347e28606fa1a608e8e02e03541a5a46e4a0152005df4a11e44f6c4ab1edd9a"
   278  //	"Status: Downloaded newer image for gcr.io/kpt-fn/starlark:v0.3"
   279  func isdockerCLIoutput(s string) bool {
   280  	if strings.Contains(s, ": Already exists") ||
   281  		strings.Contains(s, ": Pulling fs layer") ||
   282  		strings.Contains(s, ": Verifying Checksum") ||
   283  		strings.Contains(s, ": Download complete") ||
   284  		strings.Contains(s, ": Pulling from") ||
   285  		strings.Contains(s, ": Waiting") ||
   286  		strings.Contains(s, ": Pull complete") ||
   287  		strings.Contains(s, "Digest: sha256") ||
   288  		strings.Contains(s, "Status: Downloaded newer image") ||
   289  		strings.Contains(s, "Status: Image is up to date for") ||
   290  		strings.Contains(s, "Unable to find image") {
   291  		return true
   292  	}
   293  	return false
   294  }
   295  
   296  // filterPodmanCLIOutput filters out podman CLI messages
   297  // from the given buffer.
   298  func filterPodmanCLIOutput(in io.Reader) string {
   299  	s := bufio.NewScanner(in)
   300  	var lines []string
   301  
   302  	for s.Scan() {
   303  		txt := s.Text()
   304  		if !isPodmanCLIoutput(txt) {
   305  			lines = append(lines, txt)
   306  		}
   307  	}
   308  	return strings.Join(lines, "\n")
   309  }
   310  
   311  var sha256Matcher = regexp.MustCompile(`^[A-Fa-f0-9]{64}$`)
   312  
   313  // isPodmanCLIoutput is helper method to determine if
   314  // the given string is a podman CLI output message.
   315  // Example podman output:
   316  //
   317  //	"Trying to pull gcr.io/kpt-fn/starlark:v0.3..."
   318  //	"Getting image source signatures"
   319  //	"Copying blob sha256:aafbf7df3ddf625f4ababc8e55b4a09131651f9aac340b852b5f40b1a53deb65"
   320  //	"Copying config sha256:17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
   321  //	"Writing manifest to image destination"
   322  //	"Storing signatures"
   323  //	"17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
   324  func isPodmanCLIoutput(s string) bool {
   325  	if strings.Contains(s, "Trying to pull") ||
   326  		strings.Contains(s, "Getting image source signatures") ||
   327  		strings.Contains(s, "Copying blob sha256:") ||
   328  		strings.Contains(s, "Copying config sha256:") ||
   329  		strings.Contains(s, "Writing manifest to image destination") ||
   330  		strings.Contains(s, "Storing signatures") ||
   331  		sha256Matcher.MatchString(s) {
   332  		return true
   333  	}
   334  	return false
   335  }
   336  
   337  // filterNerdctlCLIOutput filters out nerdctl CLI messages
   338  // from the given buffer.
   339  func filterNerdctlCLIOutput(in io.Reader) string {
   340  	s := bufio.NewScanner(in)
   341  	var lines []string
   342  
   343  	for s.Scan() {
   344  		txt := s.Text()
   345  		if !isNerdctlCLIoutput(txt) {
   346  			lines = append(lines, txt)
   347  		}
   348  	}
   349  	return strings.Join(lines, "\n")
   350  }
   351  
   352  // isNerdctlCLIoutput is helper method to determine if
   353  // the given string is a nerdctl CLI output message.
   354  // Example nerdctl output:
   355  // docker.io/library/hello-world:latest:                                             resolving      |--------------------------------------|
   356  // docker.io/library/hello-world:latest:                                             resolved       |++++++++++++++++++++++++++++++++++++++|
   357  // index-sha256:13e367d31ae85359f42d637adf6da428f76d75dc9afeb3c21faea0d976f5c651:    done           |++++++++++++++++++++++++++++++++++++++|
   358  // manifest-sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4: done           |++++++++++++++++++++++++++++++++++++++|
   359  // config-sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412:   done           |++++++++++++++++++++++++++++++++++++++|
   360  // layer-sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54:    done           |++++++++++++++++++++++++++++++++++++++|
   361  // elapsed: 2.4 s                                                                    total:  4.4 Ki (1.9 KiB/s)
   362  func isNerdctlCLIoutput(s string) bool {
   363  	if strings.Contains(s, "index-sha256:") ||
   364  		strings.Contains(s, "Copying blob sha256:") ||
   365  		strings.Contains(s, "manifest-sha256:") ||
   366  		strings.Contains(s, "config-sha256:") ||
   367  		strings.Contains(s, "layer-sha256:") ||
   368  		strings.Contains(s, "elapsed:") ||
   369  		strings.Contains(s, "++++++++++++++++++++++++++++++++++++++") ||
   370  		strings.Contains(s, "--------------------------------------") ||
   371  		sha256Matcher.MatchString(s) {
   372  		return true
   373  	}
   374  	return false
   375  }
   376  
   377  func StringToContainerRuntime(v string) (ContainerRuntime, error) {
   378  	switch strings.ToLower(v) {
   379  	case string(Docker):
   380  		return Docker, nil
   381  	case string(Podman):
   382  		return Podman, nil
   383  	case string(Nerdctl):
   384  		return Nerdctl, nil
   385  	case "":
   386  		return Docker, nil
   387  	default:
   388  		return "", fmt.Errorf("unsupported runtime: %q the runtime must be either %s or %s", v, Docker, Podman)
   389  	}
   390  }
   391  
   392  func ContainerRuntimeAvailable(runtime ContainerRuntime) error {
   393  	switch runtime {
   394  	case Docker:
   395  		return dockerCmdAvailable()
   396  	case Podman:
   397  		return podmanCmdAvailable()
   398  	case Nerdctl:
   399  		return nerdctlCmdAvailable()
   400  	default:
   401  		return dockerCmdAvailable()
   402  	}
   403  }
   404  
   405  // dockerCmdAvailable runs `docker version` to check that the docker command is
   406  // available and is a supported version. Returns an error with installation
   407  // instructions if it is not
   408  func dockerCmdAvailable() error {
   409  	suggestedText := `docker must be running to use this command
   410  To install docker, follow the instructions at https://docs.docker.com/get-docker/.
   411  `
   412  	cmdOut := &bytes.Buffer{}
   413  
   414  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   415  	defer cancel()
   416  	cmd := exec.CommandContext(ctx, dockerBin, "version", "--format", "{{.Client.Version}}")
   417  	cmd.Stdout = cmdOut
   418  	err := cmd.Run()
   419  	if err != nil || cmdOut.String() == "" {
   420  		return fmt.Errorf("%v\n%s", err, suggestedText)
   421  	}
   422  	return isSupportedDockerVersion(strings.TrimSuffix(cmdOut.String(), "\n"))
   423  }
   424  
   425  // isSupportedDockerVersion returns an error if a given docker version is invalid
   426  // or is less than minSupportedDockerVersion
   427  func isSupportedDockerVersion(v string) error {
   428  	suggestedText := fmt.Sprintf(`docker client version must be %s or greater`, minSupportedDockerVersion)
   429  	// docker version output does not have a leading v which is required by semver, so we prefix it
   430  	currentDockerVersion := fmt.Sprintf("v%s", v)
   431  	if !semver.IsValid(currentDockerVersion) {
   432  		return fmt.Errorf("%s: found invalid version %s", suggestedText, currentDockerVersion)
   433  	}
   434  	// if currentDockerVersion is less than minDockerClientVersion, compare returns +1
   435  	if semver.Compare(minSupportedDockerVersion, currentDockerVersion) > 0 {
   436  		return fmt.Errorf("%s: found %s", suggestedText, currentDockerVersion)
   437  	}
   438  	return nil
   439  }
   440  
   441  func podmanCmdAvailable() error {
   442  	suggestedText := `podman must be installed.
   443  To install podman, follow the instructions at https://podman.io/getting-started/installation.
   444  `
   445  
   446  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   447  	defer cancel()
   448  	cmd := exec.CommandContext(ctx, podmanBin, "version")
   449  	err := cmd.Run()
   450  	if err != nil {
   451  		return fmt.Errorf("%v\n%s", err, suggestedText)
   452  	}
   453  	return nil
   454  }
   455  
   456  func nerdctlCmdAvailable() error {
   457  	suggestedText := `nerdctl must be installed.
   458  To install nerdctl, follow the instructions at https://github.com/containerd/nerdctl#install.
   459  `
   460  
   461  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   462  	defer cancel()
   463  	cmd := exec.CommandContext(ctx, nerdctlBin, "version")
   464  	err := cmd.Run()
   465  	if err != nil {
   466  		return fmt.Errorf("%v\n%s", err, suggestedText)
   467  	}
   468  	return nil
   469  }