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 }