github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/fnruntime/container.go (about)

     1  // Copyright 2021 Google LLC
     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  	"github.com/GoogleContainerTools/kpt/internal/printer"
    32  	fnresult "github.com/GoogleContainerTools/kpt/pkg/api/fnresult/v1"
    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(ctx 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, "Unable to find image") {
   290  		return true
   291  	}
   292  	return false
   293  }
   294  
   295  // filterPodmanCLIOutput filters out podman CLI messages
   296  // from the given buffer.
   297  func filterPodmanCLIOutput(in io.Reader) string {
   298  	s := bufio.NewScanner(in)
   299  	var lines []string
   300  
   301  	for s.Scan() {
   302  		txt := s.Text()
   303  		if !isPodmanCLIoutput(txt) {
   304  			lines = append(lines, txt)
   305  		}
   306  	}
   307  	return strings.Join(lines, "\n")
   308  }
   309  
   310  var sha256Matcher = regexp.MustCompile(`^[A-Fa-f0-9]{64}$`)
   311  
   312  // isPodmanCLIoutput is helper method to determine if
   313  // the given string is a podman CLI output message.
   314  // Example podman output:
   315  //
   316  //	"Trying to pull gcr.io/kpt-fn/starlark:v0.3..."
   317  //	"Getting image source signatures"
   318  //	"Copying blob sha256:aafbf7df3ddf625f4ababc8e55b4a09131651f9aac340b852b5f40b1a53deb65"
   319  //	"Copying config sha256:17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
   320  //	"Writing manifest to image destination"
   321  //	"Storing signatures"
   322  //	"17ce4f65660717ba0afbd143578dfd1c5b9822bd3ad3945c10d6878e057265f1"
   323  func isPodmanCLIoutput(s string) bool {
   324  	if strings.Contains(s, "Trying to pull") ||
   325  		strings.Contains(s, "Getting image source signatures") ||
   326  		strings.Contains(s, "Copying blob sha256:") ||
   327  		strings.Contains(s, "Copying config sha256:") ||
   328  		strings.Contains(s, "Writing manifest to image destination") ||
   329  		strings.Contains(s, "Storing signatures") ||
   330  		sha256Matcher.MatchString(s) {
   331  		return true
   332  	}
   333  	return false
   334  }
   335  
   336  // filterNerdctlCLIOutput filters out nerdctl CLI messages
   337  // from the given buffer.
   338  func filterNerdctlCLIOutput(in io.Reader) string {
   339  	s := bufio.NewScanner(in)
   340  	var lines []string
   341  
   342  	for s.Scan() {
   343  		txt := s.Text()
   344  		if !isNerdctlCLIoutput(txt) {
   345  			lines = append(lines, txt)
   346  		}
   347  	}
   348  	return strings.Join(lines, "\n")
   349  }
   350  
   351  // isNerdctlCLIoutput is helper method to determine if
   352  // the given string is a nerdctl CLI output message.
   353  // Example nerdctl output:
   354  // docker.io/library/hello-world:latest:                                             resolving      |--------------------------------------|
   355  // docker.io/library/hello-world:latest:                                             resolved       |++++++++++++++++++++++++++++++++++++++|
   356  // index-sha256:13e367d31ae85359f42d637adf6da428f76d75dc9afeb3c21faea0d976f5c651:    done           |++++++++++++++++++++++++++++++++++++++|
   357  // manifest-sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4: done           |++++++++++++++++++++++++++++++++++++++|
   358  // config-sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412:   done           |++++++++++++++++++++++++++++++++++++++|
   359  // layer-sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54:    done           |++++++++++++++++++++++++++++++++++++++|
   360  // elapsed: 2.4 s                                                                    total:  4.4 Ki (1.9 KiB/s)
   361  func isNerdctlCLIoutput(s string) bool {
   362  	if strings.Contains(s, "index-sha256:") ||
   363  		strings.Contains(s, "Copying blob sha256:") ||
   364  		strings.Contains(s, "manifest-sha256:") ||
   365  		strings.Contains(s, "config-sha256:") ||
   366  		strings.Contains(s, "layer-sha256:") ||
   367  		strings.Contains(s, "elapsed:") ||
   368  		strings.Contains(s, "++++++++++++++++++++++++++++++++++++++") ||
   369  		strings.Contains(s, "--------------------------------------") ||
   370  		sha256Matcher.MatchString(s) {
   371  		return true
   372  	}
   373  	return false
   374  }
   375  
   376  func StringToContainerRuntime(v string) (ContainerRuntime, error) {
   377  	switch strings.ToLower(v) {
   378  	case string(Docker):
   379  		return Docker, nil
   380  	case string(Podman):
   381  		return Podman, nil
   382  	case string(Nerdctl):
   383  		return Nerdctl, nil
   384  	case "":
   385  		return Docker, nil
   386  	default:
   387  		return "", fmt.Errorf("unsupported runtime: %q the runtime must be either %s or %s", v, Docker, Podman)
   388  	}
   389  }
   390  
   391  func ContainerRuntimeAvailable(runtime ContainerRuntime) error {
   392  	switch runtime {
   393  	case Docker:
   394  		return dockerCmdAvailable()
   395  	case Podman:
   396  		return podmanCmdAvailable()
   397  	case Nerdctl:
   398  		return nerdctlCmdAvailable()
   399  	default:
   400  		return dockerCmdAvailable()
   401  	}
   402  }
   403  
   404  // dockerCmdAvailable runs `docker version` to check that the docker command is
   405  // available and is a supported version. Returns an error with installation
   406  // instructions if it is not
   407  func dockerCmdAvailable() error {
   408  	suggestedText := `docker must be running to use this command
   409  To install docker, follow the instructions at https://docs.docker.com/get-docker/.
   410  `
   411  	cmdOut := &bytes.Buffer{}
   412  
   413  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   414  	defer cancel()
   415  	cmd := exec.CommandContext(ctx, dockerBin, "version", "--format", "{{.Client.Version}}")
   416  	cmd.Stdout = cmdOut
   417  	err := cmd.Run()
   418  	if err != nil || cmdOut.String() == "" {
   419  		return fmt.Errorf("%v\n%s", err, suggestedText)
   420  	}
   421  	return isSupportedDockerVersion(strings.TrimSuffix(cmdOut.String(), "\n"))
   422  }
   423  
   424  // isSupportedDockerVersion returns an error if a given docker version is invalid
   425  // or is less than minSupportedDockerVersion
   426  func isSupportedDockerVersion(v string) error {
   427  	suggestedText := fmt.Sprintf(`docker client version must be %s or greater`, minSupportedDockerVersion)
   428  	// docker version output does not have a leading v which is required by semver, so we prefix it
   429  	currentDockerVersion := fmt.Sprintf("v%s", v)
   430  	if !semver.IsValid(currentDockerVersion) {
   431  		return fmt.Errorf("%s: found invalid version %s", suggestedText, currentDockerVersion)
   432  	}
   433  	// if currentDockerVersion is less than minDockerClientVersion, compare returns +1
   434  	if semver.Compare(minSupportedDockerVersion, currentDockerVersion) > 0 {
   435  		return fmt.Errorf("%s: found %s", suggestedText, currentDockerVersion)
   436  	}
   437  	return nil
   438  }
   439  
   440  func podmanCmdAvailable() error {
   441  	suggestedText := `podman must be installed.
   442  To install podman, follow the instructions at https://podman.io/getting-started/installation.
   443  `
   444  
   445  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   446  	defer cancel()
   447  	cmd := exec.CommandContext(ctx, podmanBin, "version")
   448  	err := cmd.Run()
   449  	if err != nil {
   450  		return fmt.Errorf("%v\n%s", err, suggestedText)
   451  	}
   452  	return nil
   453  }
   454  
   455  func nerdctlCmdAvailable() error {
   456  	suggestedText := `nerdctl must be installed.
   457  To install nerdctl, follow the instructions at https://github.com/containerd/nerdctl#install.
   458  `
   459  
   460  	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
   461  	defer cancel()
   462  	cmd := exec.CommandContext(ctx, nerdctlBin, "version")
   463  	err := cmd.Run()
   464  	if err != nil {
   465  		return fmt.Errorf("%v\n%s", err, suggestedText)
   466  	}
   467  	return nil
   468  }