From 62a022428231df50637495673354d4fdd2541c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Mon, 7 Jun 2021 14:11:27 +0200 Subject: [PATCH 1/7] pkg/podman: Support parsing 'podman pull' spew into a Go error This is meant to get a better understanding of a failed 'podman pull' invocation to understand whether pulling an image requires logging into the registry or not. Currently, 'podman pull' doesn't have a dedicated exit code to denote authorization errors, so this is meant to be a temporary workaround for that. Parsing the error stream is inherently fragile and tricky because there's no guarantee that the structure of the messages won't change, and there's no clear definition of how the messages are laid out. Therefore, this approach can't be treated as a generic solution for getting detailed information about failed Podman invocations. The error stream is used not only for dumping error messages, but also for showing progress bars. Therefore, all lines are skipped until one that starts with "Error: " is found. This is a heuristic based on how Go programs written using the Cobra [1] library tend to report errors. All subsequent lines are taken together and split around the ": " sub-string, on the assumption that the ": " sub-string is used when a new error message is prefixed to an inner error. Each sub-string created from the split is treated as a potential member of the chain of errors reported within Podman. Some real world examples of the 'podman pull' error stream in the case of authorization errors are: * With Docker Hub (https://hub.docker.com/): Trying to pull docker.io/library/foobar:latest... Error: Error initializing source docker://foobar:latest: Error reading manifest latest in docker.io/library/foobar: errors: denied: requested access to the resource is denied unauthorized: authentication required * With registry.redhat.io: Trying to pull registry.redhat.io/foobar:latest... Error: Error initializing source docker://registry.redhat.io/foobar:latest: unable to retrieve auth token: invalid username/password: unauthorized: Please login to the Red Hat Registry using your Customer Portal credentials. Further instructions can be found here: https://access.redhat.com/RegistryAuthentication [1] https://github.com/spf13/cobra/ https://pkg.go.dev/github.com/spf13/cobra https://github.com/containers/toolbox/pull/786 https://github.com/containers/toolbox/pull/787 --- src/pkg/podman/error.go | 109 +++++++++++++++++++++++++ src/pkg/podman/error_test.go | 152 +++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/pkg/podman/error.go create mode 100644 src/pkg/podman/error_test.go diff --git a/src/pkg/podman/error.go b/src/pkg/podman/error.go new file mode 100644 index 000000000000..ea35de8d7ed8 --- /dev/null +++ b/src/pkg/podman/error.go @@ -0,0 +1,109 @@ +/* + * Copyright © 2021 Red Hat Inc. + * + * 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 podman + +import ( + "bufio" + "bytes" + "strings" +) + +// internalError serves for representing errors printed by Podman to stderr +type internalError struct { + errors []string +} + +func (e *internalError) Error() string { + if e.errors == nil || len(e.errors) == 0 { + return "" + } + + var builder strings.Builder + + for i, part := range e.errors { + if i != 0 { + builder.WriteString(": ") + } + + builder.WriteString(part) + } + + return builder.String() +} + +// Is lexically compares errors +// +// The comparison is done for every part in the error chain not across. +func (e *internalError) Is(target error) bool { + if target == nil { + return false + } + + if e.errors == nil || len(e.errors) == 0 { + return false + } + + for _, part := range e.errors { + if part == target.Error() { + return true + } + } + + return false +} + +func (e *internalError) Unwrap() error { + if e.errors == nil || len(e.errors) <= 1 { + return nil + } + + return &internalError{e.errors[1:]} +} + +// parseErrorMsg serves for converting error output of Podman into an error +// that can be further used in Go +func parseErrorMsg(stderr *bytes.Buffer) error { + // Stderr is not used only for error messages but also for things like + // progress bars. We're only interested in the error messages. + + var errMsgFound bool + var errMsgParts []string + + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, "Error: ") { + line = strings.TrimPrefix(line, "Error: ") + errMsgFound = true + } + + if errMsgFound { + line = strings.TrimSpace(line) + line = strings.Trim(line, ":") + + parts := strings.Split(line, ": ") + errMsgParts = append(errMsgParts, parts...) + } + } + + if !errMsgFound { + return nil + } + + return &internalError{errMsgParts} +} diff --git a/src/pkg/podman/error_test.go b/src/pkg/podman/error_test.go new file mode 100644 index 000000000000..11652c709bdd --- /dev/null +++ b/src/pkg/podman/error_test.go @@ -0,0 +1,152 @@ +/* + * Copyright © 2021 Red Hat Inc. + * + * 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 podman + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInternalError(t *testing.T) { + type expect struct { + IsNil bool + Error string + Search string + Wrap []string + } + + testCases := []struct { + name string + input string + expect expect + }{ + { + name: "Empty", + input: "", + expect: expect{ + IsNil: true, + Error: "", + }, + }, + { + name: "Text with only a prolog and no error message", + input: "There is only a prolog and no error message", + expect: expect{ + IsNil: true, + Error: "", + }, + }, + { + name: "Text with only a prolog and no error message", + input: "There is only a prolog Error: not an error message", + expect: expect{ + IsNil: true, + Error: "", + }, + }, + { + name: "Text with a prolog before the error message", + input: `There is a prolog +Error: an error message`, + expect: expect{ + Error: "an error message", + Search: "an error message", + }, + }, + { + name: "Error message with several wrapped errors", + input: "Error: level 1: level 2: level 3: level 4", + expect: expect{ + Error: "level 1: level 2: level 3: level 4", + Search: "level 4", + Wrap: []string{"level 1", "level 2", "level 3", "level 4"}, + }, + }, + { + name: "Error message with a bullet list", + input: `Error: an error message: + err1 + err2 + err3`, + expect: expect{ + Error: "an error message: err1: err2: err3", + Search: "err2", + Wrap: []string{"an error message", "err1", "err2", "err3"}, + }, + }, + { + name: "Error message from 'podman pull' - unauthorized (Docker Hub)", + input: `Trying to pull docker.io/library/foobar:latest... +Error: Error initializing source docker://foobar:latest: Error reading manifest latest in docker.io/library/foobar: errors: +denied: requested access to the resource is denied +unauthorized: authentication required`, + expect: expect{ + Error: "Error initializing source docker://foobar:latest: Error reading manifest latest in docker.io/library/foobar: errors: denied: requested access to the resource is denied: unauthorized: authentication required", + }, + }, + { + name: "Error message from 'podman pull' - unauthorized (Red Hat Registry)", + input: `Trying to pull registry.redhat.io/foobar:latest... +Error: Error initializing source docker://registry.redhat.io/foobar:latest: unable to retrieve auth token: invalid username/password: unauthorized: Please login to the Red Hat Registry using your Customer Portal credentials. Further instructions can be found here: https://access.redhat.com/RegistryAuthentication +`, + expect: expect{ + Error: "Error initializing source docker://registry.redhat.io/foobar:latest: unable to retrieve auth token: invalid username/password: unauthorized: Please login to the Red Hat Registry using your Customer Portal credentials. Further instructions can be found here: https://access.redhat.com/RegistryAuthentication", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := parseErrorMsg(bytes.NewBufferString(tc.input)) + + if tc.expect.IsNil { + assert.Nil(t, err) + return + } else { + assert.NotNil(t, err) + } + + errInternal := err.(*internalError) + assert.Equal(t, tc.expect.Error, errInternal.Error()) + + if tc.expect.Search != "" { + assert.True(t, errInternal.Is(errors.New(tc.expect.Search))) + } + + if len(tc.expect.Wrap) != 0 { + for { + assert.Equal(t, len(tc.expect.Wrap), len(errInternal.errors)) + + for i, part := range tc.expect.Wrap { + assert.Equal(t, part, errInternal.errors[i]) + } + + err = errInternal.Unwrap() + if err == nil { + assert.Equal(t, len(tc.expect.Wrap), 1) + break + } + errInternal = err.(*internalError) + tc.expect.Wrap = tc.expect.Wrap[1:] + } + } + }) + } +} -- 2.31.1 From 146c93b431941d21b3c686734a7b83a737072004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Sat, 3 Jul 2021 22:06:30 +0200 Subject: [PATCH 2/7] pkg/shell: Allow extracting the stderr even when it's shown to the user This way the standard error stream of the spawned binaries can be inspected to get a better understanding of the failure, while still being shown to the user when run with the '--verbose' flag. Unfortunately, this breaks the progress bar in 'podman pull' because the standard error stream is no longer connected to a file descriptor that's a terminal device. https://github.com/containers/toolbox/pull/787 https://github.com/containers/toolbox/pull/823 --- src/pkg/shell/shell.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/pkg/shell/shell.go b/src/pkg/shell/shell.go index 272dcc9ca693..c63b85bc8745 100644 --- a/src/pkg/shell/shell.go +++ b/src/pkg/shell/shell.go @@ -40,15 +40,12 @@ func Run(name string, stdin io.Reader, stdout, stderr io.Writer, arg ...string) } func RunWithExitCode(name string, stdin io.Reader, stdout, stderr io.Writer, arg ...string) (int, error) { - logLevel := logrus.GetLevel() - if stderr == nil && logLevel >= logrus.DebugLevel { - stderr = os.Stderr - } + stderrWrapper := getStderrWrapper(stderr) cmd := exec.Command(name, arg...) cmd.Stdin = stdin cmd.Stdout = stdout - cmd.Stderr = stderr + cmd.Stderr = stderrWrapper if err := cmd.Run(); err != nil { if errors.Is(err, exec.ErrNotFound) { @@ -66,3 +63,18 @@ func RunWithExitCode(name string, stdin io.Reader, stdout, stderr io.Writer, arg return 0, nil } + +func getStderrWrapper(buffer io.Writer) io.Writer { + var stderr io.Writer + + logLevel := logrus.GetLevel() + if logLevel < logrus.DebugLevel { + stderr = buffer + } else if buffer == nil { + stderr = os.Stderr + } else { + stderr = io.MultiWriter(buffer, os.Stderr) + } + + return stderr +} -- 2.31.1 From 5c3249033224c6f107a2d39ab84e519303d74072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Sun, 4 Jul 2021 18:15:16 +0200 Subject: [PATCH 3/7] pkg/podman: Try to parse the error from 'podman pull' into a Go error https://github.com/containers/toolbox/pull/787 --- src/pkg/podman/podman.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pkg/podman/podman.go b/src/pkg/podman/podman.go index 9099df1eaf2a..8e7d5068fb97 100644 --- a/src/pkg/podman/podman.go +++ b/src/pkg/podman/podman.go @@ -228,10 +228,13 @@ func IsToolboxImage(image string) (bool, error) { // Pull pulls an image func Pull(imageName string) error { + var stderr bytes.Buffer + logLevelString := LogLevel.String() args := []string{"--log-level", logLevelString, "pull", imageName} - if err := shell.Run("podman", nil, nil, nil, args...); err != nil { + if err := shell.Run("podman", nil, nil, &stderr, args...); err != nil { + err := parseErrorMsg(&stderr) return err } -- 2.31.1 From 4b8b007201cd019909829d73afdcf919753943a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Sun, 4 Jul 2021 18:16:12 +0200 Subject: [PATCH 4/7] pkg/podman: Add error for 'podman pull' failing due to no authorization https://github.com/containers/toolbox/pull/787 --- src/pkg/podman/podman.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pkg/podman/podman.go b/src/pkg/podman/podman.go index 8e7d5068fb97..521538c9abfb 100644 --- a/src/pkg/podman/podman.go +++ b/src/pkg/podman/podman.go @@ -19,6 +19,7 @@ package podman import ( "bytes" "encoding/json" + "errors" "fmt" "io" @@ -32,7 +33,8 @@ var ( ) var ( - LogLevel = logrus.ErrorLevel + ErrUnauthorized = errors.New("unauthorized") + LogLevel = logrus.ErrorLevel ) // CheckVersion compares provided version with the version of Podman. -- 2.31.1 From ef3bebac89cd833a663dc63fffba8b04ca944dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Sat, 3 Jul 2021 02:25:49 +0200 Subject: [PATCH 5/7] pkg/podman: Wrap 'podman login' https://github.com/containers/toolbox/pull/787 --- src/pkg/podman/podman.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/pkg/podman/podman.go b/src/pkg/podman/podman.go index 521538c9abfb..8538bd7c617a 100644 --- a/src/pkg/podman/podman.go +++ b/src/pkg/podman/podman.go @@ -228,6 +228,18 @@ func IsToolboxImage(image string) (bool, error) { return true, nil } +func Login(registry, username, password string) error { + logLevelString := LogLevel.String() + args := []string{"--log-level", logLevelString, "login", registry, "--password-stdin", "--username", username} + stdin := bytes.NewBufferString(password) + + if err := shell.Run("podman", stdin, nil, nil, args...); err != nil { + return err + } + + return nil +} + // Pull pulls an image func Pull(imageName string) error { var stderr bytes.Buffer -- 2.31.1 From 49cd3c48c26d37c85aba5dbdcf04a995593ae673 Mon Sep 17 00:00:00 2001 From: Debarshi Ray Date: Sun, 4 Jul 2021 18:11:04 +0200 Subject: [PATCH 6/7] cmd/create: Split out the spinner around 'podman pull' A subsequent commit will use this when retrying the 'podman pull' after logging the user into the registry for images that require authorization. https://github.com/containers/toolbox/pull/787 --- src/cmd/create.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cmd/create.go b/src/cmd/create.go index e3245b4c5ae3..6a3005f06041 100644 --- a/src/cmd/create.go +++ b/src/cmd/create.go @@ -731,6 +731,14 @@ func pullImage(image, release string) (bool, error) { return false, nil } + if _, err := pullImageWithSpinner(imageFull); err != nil { + return false, fmt.Errorf("failed to pull image %s", imageFull) + } + + return true, nil +} + +func pullImageWithSpinner(imageFull string) (bool, error) { logrus.Debugf("Pulling image %s", imageFull) stdoutFd := os.Stdout.Fd() @@ -744,7 +752,7 @@ func pullImage(image, release string) (bool, error) { } if err := podman.Pull(imageFull); err != nil { - return false, fmt.Errorf("failed to pull image %s", imageFull) + return false, err } return true, nil -- 2.31.1 From 19bb0f08118536c32ff2d2f571d92758dff00dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20M=C3=ADchal?= Date: Sun, 4 Jul 2021 18:11:28 +0200 Subject: [PATCH 7/7] cmd/create: Support logging into a registry if necessary Some registries contain private repositories of images and require the user to log in first to gain access. With this Toolbox tries to recognize errors when pulling images and offers the user the means to log in. Some changes by Debarshi Ray. https://github.com/containers/toolbox/pull/787 --- src/cmd/create.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/cmd/create.go b/src/cmd/create.go index 6a3005f06041..7cf03e8a4cd2 100644 --- a/src/cmd/create.go +++ b/src/cmd/create.go @@ -17,6 +17,7 @@ package cmd import ( + "bufio" "errors" "fmt" "os" @@ -668,6 +669,65 @@ func isPathReadWrite(path string) (bool, error) { return false, nil } +func logIntoRegistry(imageFull, registry string) (bool, error) { + fmt.Printf("Image %s requires log-in.\n", imageFull) + + scanner := bufio.NewScanner(os.Stdin) + + stdinFd := os.Stdin.Fd() + stdinFdInt := int(stdinFd) + + if terminal.IsTerminal(stdinFdInt) { + fmt.Printf("Username: ") + } + + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + logrus.Debugf("Logging into registry: failed to read username: %s", err) + } + + return false, errors.New("failed to read username") + } + + username := scanner.Text() + + var password string + + if terminal.IsTerminal(stdinFdInt) { + logrus.Debug("Reading password from a terminal input") + + fmt.Printf("Password: ") + + passwordBytes, err := terminal.ReadPassword(stdinFdInt) + if err != nil { + logrus.Debugf("Logging into registry: failed to read password: %s", err) + return false, errors.New("failed to read password") + } + + password = string(passwordBytes) + fmt.Println("") + } else { + logrus.Debug("Reading password from a non-terminal input") + + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + logrus.Debugf("Logging into registry: failed to read password: %s", err) + } + + return false, errors.New("failed to read password") + } + + password = scanner.Text() + } + + if err := podman.Login(registry, username, password); err != nil { + logrus.Debugf("Logging into registry %s failed: %s", registry, err) + return false, fmt.Errorf("failed to log into registry %s", registry) + } + + return true, nil +} + func pullImage(image, release string) (bool, error) { if _, err := utils.ImageReferenceCanBeID(image); err == nil { logrus.Debugf("Looking for image %s", image) @@ -732,7 +792,17 @@ func pullImage(image, release string) (bool, error) { } if _, err := pullImageWithSpinner(imageFull); err != nil { - return false, fmt.Errorf("failed to pull image %s", imageFull) + if !errors.Is(err, podman.ErrUnauthorized) { + return false, fmt.Errorf("failed to pull image %s", imageFull) + } + + if _, err := logIntoRegistry(imageFull, domain); err != nil { + return false, err + } + + if _, err := pullImageWithSpinner(imageFull); err != nil { + return false, fmt.Errorf("failed to pull image %s", imageFull) + } } return true, nil -- 2.31.1