Skip to content

Commit

Permalink
cmd/create, pkg/podman: Add capabilities for logging to registries
Browse files Browse the repository at this point in the history
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 offer the user the means to log
in or tell the user that the requested image/manifest does not exist.

The parsing process relies on error messages that match the OCI
specification[0].

Testing of this feature is tricky as it relies on network which is
inherently flaky. So, as part of the setup two local image registries
are created and these features can be tested against them. To prevent
the removal of the registries during testing a flag --namespace is used
in Podman during their creation.

[0] https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes

#787
  • Loading branch information
HarryMichal committed Jun 11, 2021
1 parent afd427d commit b160671
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 9 deletions.
26 changes: 25 additions & 1 deletion doc/toolbox-create.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ toolbox\-create - Create a new toolbox container
Creates a new toolbox container. You can then use the `toolbox enter` command
to interact with the container at any point.

A requested image may live in a registry requiring authentication. Toolbox
tries to recognize such cases and offer the user an interactive prompt for
loging into the registry and then retrying to pull the image.

A toolbox container is an OCI container created from an OCI image. On Fedora,
the default image is known as `fedora-toolbox:N`, where N is the release of
the host. If the image is not present locally, then it is pulled from a
Expand Down Expand Up @@ -63,6 +67,25 @@ containers. Read more about the entry-point in `toolbox-init-container(1)`.

## OPTIONS ##

**--authfile** AUTHFILE

Path of the authentication file. Default is ${XDG\_RUNTIME\_DIR}/containers/auth.json

Note: You can also override the default path of the authentication file by
setting the REGISTRY\_AUTH\_FILE environment variable. `export REGISTRY_AUTH_FILE=path`

Note: This option is equal to one in `podman-create(1)`.

**--creds** USERNAME:PASSWORD

Credentials used to authenticate with a registry if required. Both values have
to be provided beforehand.

Without this option an attempt to log into a registry will not be made if the
global `--assumeyes` option is used.

Note: This option is analogical to one in `podman-pull(1)`.

**--distro** DISTRO, **-d** DISTRO

Create a toolbox container for a different operating system DISTRO than the
Expand Down Expand Up @@ -104,4 +127,5 @@ $ toolbox create --image bar foo

## SEE ALSO

`toolbox(1)`, `toolbox-init-container(1)`, `podman(1)`, `podman-create(1)`
`toolbox(1)`, `toolbox-init-container(1)`, `podman(1)`, `podman-create(1)`,
`podman-pull(1)`
98 changes: 96 additions & 2 deletions src/cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ const (

var (
createFlags struct {
authfile string
container string
creds string
distro string
image string
release string
Expand All @@ -66,12 +68,22 @@ var createCmd = &cobra.Command{
func init() {
flags := createCmd.Flags()

flags.StringVar(&createFlags.authfile,
"authfile",
"",
"Path of the authentication file")

flags.StringVarP(&createFlags.container,
"container",
"c",
"",
"Assign a different name to the toolbox container")

flags.StringVar(&createFlags.creds,
"creds",
"",
"Credentials (USERNAME:PASSWORD) to use for authenticating to a registry")

flags.StringVarP(&createFlags.distro,
"distro",
"d",
Expand Down Expand Up @@ -115,6 +127,17 @@ func create(cmd *cobra.Command, args []string) error {
return errors.New("options --image and --release cannot be used together")
}

if cmd.Flag("creds").Changed {
if strings.Count(createFlags.creds, ":") != 1 {
return errors.New("option --creds accepts values in format USERNAME:PASSWORD")
}

creds := strings.Split(createFlags.creds, ":")
if creds[0] == "" || creds[1] == "" {
return errors.New("option --creds requires both parts of value to have at least 1 character")
}
}

var container string
var containerArg string

Expand Down Expand Up @@ -656,6 +679,8 @@ func isPathReadWrite(path string) (bool, error) {
}

func pullImage(image, release string) (bool, error) {
var didLogin bool = false

if _, err := utils.ImageReferenceCanBeID(image); err == nil {
logrus.Debugf("Looking for image %s", image)

Expand Down Expand Up @@ -718,20 +743,89 @@ func pullImage(image, release string) (bool, error) {
return false, nil
}

pull_image:
logrus.Debugf("Pulling image %s", imageFull)

stdoutFd := os.Stdout.Fd()
stdoutFdInt := int(stdoutFd)
var s *spinner.Spinner = spinner.New(spinner.CharSets[9], 500*time.Millisecond)
if logLevel := logrus.GetLevel(); logLevel < logrus.DebugLevel && terminal.IsTerminal(stdoutFdInt) {
s := spinner.New(spinner.CharSets[9], 500*time.Millisecond)
s.Prefix = fmt.Sprintf("Pulling %s: ", imageFull)
s.Writer = os.Stdout
s.Start()
defer s.Stop()
}

if err := podman.Pull(imageFull); err != nil {
return false, fmt.Errorf("failed to pull image %s", imageFull)
if errors.Is(err, podman.ErrNameUnknown) || errors.Is(err, podman.ErrManifestUnknown) {
var builder strings.Builder
fmt.Fprintf(&builder, "The requested image does not exist\n")
fmt.Fprintf(&builder, "Make sure the image URI is correct.")
errMsg := errors.New(builder.String())
return false, errMsg
}

if errors.Is(err, podman.ErrUnauthorized) {
var builder strings.Builder

// This error should not be encountered for the second time after
// logging into a registry
if didLogin {
return false, fmt.Errorf("failed to pull image %s: unexpected unauthorized access", imageFull)
}

fmt.Fprintf(&builder, "Could not pull image %s\n", imageFull)
fmt.Fprintf(&builder, "The registry requires logging in.\n")

if rootFlags.assumeYes {
// We don't want to block when no credentials were provided
if createFlags.creds == "" {
fmt.Fprintf(&builder, "See 'podman login --help' on how to login into a registry.")
errMsg := errors.New(builder.String())
return false, errMsg
}

s.Stop()
fmt.Fprintf(&builder, "Credentials were provided. Trying to log into %s\n", domain)
fmt.Fprintf(os.Stderr, builder.String())

creds := strings.Split(createFlags.creds, ":")
args := []string{"--username", creds[0], "--password", creds[1]}
if err = podman.Login(domain, args...); err != nil {
return false, err
}
}

if !rootFlags.assumeYes {
s.Stop()
fmt.Fprintf(os.Stderr, builder.String())

if !utils.AskForConfirmation("Do you want to log into the registry and try to pull the image again? [y/N]") {
return false, nil
}

var args []string

if createFlags.authfile != "" {
args = append(args, "--authfile", createFlags.authfile)
}

if createFlags.creds != "" {
creds := strings.Split(createFlags.creds, ":")
args = append(args, "--username", creds[0], "--password", creds[1])
}

if err = podman.Login(domain, args...); err != nil {
return false, err
}
}

fmt.Fprintf(os.Stderr, "Retrying to pull image %s\n", imageFull)
didLogin = true
goto pull_image
}

return false, err
}

return true, nil
Expand Down
74 changes: 69 additions & 5 deletions src/pkg/podman/podman.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"errors"
"fmt"
"io"
"sort"
"os"
"strings"

"github.com/HarryMichal/go-version"
Expand All @@ -38,6 +38,12 @@ var (
LogLevel = logrus.ErrorLevel
)

var (
ErrUnauthorized = errors.New("not authorized to access resources in registry")
ErrManifestUnknown = errors.New("registry does not know the requested manifest")
ErrNameUnknown = errors.New("registry does not know the requested repository name")
)

// CheckVersion compares provided version with the version of Podman.
//
// Takes in one string parameter that should be in the format that is used for versioning (eg. 1.0.0, 2.5.1-dev).
Expand Down Expand Up @@ -229,13 +235,71 @@ func IsToolboxImage(image string) (bool, error) {
return true, nil
}

// Pull pulls an image
func Login(registry string, extraArgs ...string) error {
var stderr bytes.Buffer

logLevelString := LogLevel.String()
args := []string{"--log-level", logLevelString, "login", registry}
args = append(args, extraArgs...)

if err := shell.Run("podman", os.Stdin, os.Stdout, &stderr, args...); err != nil {
if stderr.Len() == 0 {
return fmt.Errorf("failed to login to registry %s: %w", registry, err)
}

err := parseErrorMsg(&stderr)
return fmt.Errorf("failed to login to registry %s: %w", registry, err.Err)
}

return nil
}

// Pull pulls an image. Wraps around command 'podman pull'.
//
// The image pull can fail for many reasons. The recognized reasons are:
// - manifest unknown (ErrManifestUnknown)
// - name unknown (ErrNameUnknown)
// - unauthorized (ErrUnauthorized)
//
// To learn more about OCI API error codes, see: https://github.com/opencontainers/distribution-spec/blob/main/spec.md
//
// The wrapper tries to discern these errors to some extent and provide means
// for their handling. The provided errors can not be relied on completely as
// they are created based on error message parsing that differs based on used
// registry.
func Pull(imageName string) error {
var stderr bytes.Buffer

logLevelString := LogLevel.String()
args := []string{"--log-level", logLevelString, "pull", imageName}
args := []string{"--log-level", logLevelString, "pull", imageName, "--quiet"}

if err := shell.Run("podman", nil, nil, nil, args...); err != nil {
return err
if err := shell.Run("podman", nil, nil, &stderr, args...); err != nil {
if stderr.Len() == 0 {
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
}

err := parseErrorMsg(&stderr)

// This error is returned when the manifest, identified by name and tag
// is unknown to the repository.
if err.Is(errors.New("manifest unknown")) {
return ErrManifestUnknown
}

// This is returned if the name used during an operation is unknown to the
// registry.
if err.Is(errors.New("name unknown")) {
return ErrNameUnknown
}

// The access controller was unable to authenticate the client. Often this
// will be accompanied by a Www-Authenticate HTTP response header
// indicating how to authenticate.
if err.Is(errors.New("unauthorized")) {
return ErrUnauthorized
}

return fmt.Errorf("failed to pull image %s: %w", imageName, err.Err)
}

return nil
Expand Down
50 changes: 50 additions & 0 deletions test/system/000-setup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,54 @@ load 'libs/helpers'
_pull_and_cache_distro_image fedora 32 || die
_pull_and_cache_distro_image rhel 8.4 || die
_pull_and_cache_distro_image busybox || die

# Prepare localy hosted image registries
# The registries need to live in a separate instance of Podman to prevent
# them from being removed using `podman system reset`.

# Create certificates for HTTPS
mkdir -p "$BATS_CORE_TMPDIR"/certs
run openssl req \
-newkey rsa:4096 \
-nodes -sha256 \
-keyout "$BATS_CORE_TMPDIR"/certs/domain.key \
-addext "subjectAltName = DNS:localhost" \
-x509 \
-days 365 \
-subj '/' \
-out "$BATS_CORE_TMPDIR"/certs/domain.crt
assert_success

# Create testing registry user
mkdir -p "$BATS_CORE_TMPDIR"/auth
run $PODMAN --namespace registries run \
--entrypoint htpasswd \
httpd:2 -Bbn testuser testpassword > "$BATS_CORE_TMPDIR"/auth/htpasswd
assert_success

# Create a Docker registry without authentication
run $PODMAN --namespace registries run -d \
--name docker-registry-noauth \
-v "$BATS_CORE_TMPDIR"/certs:/certs \
-e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-p 5000:443 \
docker.io/library/registry:2
assert_success

# Create a Docker registry with authentication
$PODMAN --namespace registries run -d \
--name docker-registry-auth \
-v "$BATS_CORE_TMPDIR"/auth:/auth \
-e "REGISTRY_AUTH=htpasswd" \
-e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
-v "$BATS_CORE_TMPDIR"/certs:/certs \
-e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-p 5001:443 \
docker.io/library/registry:2
assert_success
}
Loading

0 comments on commit b160671

Please sign in to comment.