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 }