docker: return most relevant error from docker resolution

Previously, we would return the first non-404 error from a host.

This is logical, however, it can result in confusing errors to the
user:

- e.g. we have an HTTP host, and an HTTPS host.

  If the image does not exist, we return "http: server gave HTTP
  response to HTTPS client". This is technically correct, however, the
  user is easily confused - the most relevant error in this case is the
  404 error.

- e.g. we have a broken HTTP host that returns 5XX errors, and a HTTP
  host with authentication.

  On the request for an image, we return the 5XX error directly.
  However, we have a host later on which returned an authentication
  error which is now hidden from the user.

Note: this *can* be resolved by changing the order of hosts passed in,
however this requires 1. knowing ahead of time which hosts are going to
return certain errors and 2. this is often not desirable, we'd prefer
to use HTTPS if it's available, and only then fallback to HTTP.

To resolve this, we assign each possible error during resolution a
"priority" that marks how far through the image resolution process a
host/path combo got. Then we return the error with the highest priority,
which is much more likely to be the most relevant error to the user.

The ranking of priority then is (from lowest to highest):
- Underlying transport errors (TLS, TCP, etc)
- 404 errors
- Other 4XX/5XX errors
- Manifest rejection (due to max size exceeded)

Signed-off-by: Justin Chadwell <me@jedevc.com>
This commit is contained in:
Justin Chadwell 2023-07-24 15:29:49 +01:00
parent a3404ac422
commit b4814a29d4

View File

@ -241,10 +241,9 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
} }
var ( var (
firstErr error paths [][]string
paths [][]string dgst = refspec.Digest()
dgst = refspec.Digest() caps = HostCapabilityPull
caps = HostCapabilityPull
) )
if dgst != "" { if dgst != "" {
@ -275,6 +274,13 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
return "", ocispec.Descriptor{}, err return "", ocispec.Descriptor{}, err
} }
var (
// firstErr is the most relevant error encountered during resolution.
// We use this to determine the error to return, making sure that the
// error created furthest through the resolution process is returned.
firstErr error
firstErrPriority int
)
for _, u := range paths { for _, u := range paths {
for _, host := range hosts { for _, host := range hosts {
ctx := log.WithLogger(ctx, log.G(ctx).WithField("host", host.Host)) ctx := log.WithLogger(ctx, log.G(ctx).WithField("host", host.Host))
@ -294,9 +300,9 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
if errors.Is(err, ErrInvalidAuthorization) { if errors.Is(err, ErrInvalidAuthorization) {
err = fmt.Errorf("pull access denied, repository does not exist or may require authorization: %w", err) err = fmt.Errorf("pull access denied, repository does not exist or may require authorization: %w", err)
} }
// Store the error for referencing later if firstErrPriority < 1 {
if firstErr == nil {
firstErr = err firstErr = err
firstErrPriority = 1
} }
log.G(ctx).WithError(err).Info("trying next host") log.G(ctx).WithError(err).Info("trying next host")
continue // try another host continue // try another host
@ -305,13 +311,17 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
if resp.StatusCode > 299 { if resp.StatusCode > 299 {
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
if firstErrPriority < 2 {
firstErr = fmt.Errorf("%s: %w", ref, errdefs.ErrNotFound)
firstErrPriority = 2
}
log.G(ctx).Info("trying next host - response was http.StatusNotFound") log.G(ctx).Info("trying next host - response was http.StatusNotFound")
continue continue
} }
if resp.StatusCode > 399 { if resp.StatusCode > 399 {
// Set firstErr when encountering the first non-404 status code. if firstErrPriority < 3 {
if firstErr == nil {
firstErr = remoteerrors.NewUnexpectedStatusErr(resp) firstErr = remoteerrors.NewUnexpectedStatusErr(resp)
firstErrPriority = 3
} }
log.G(ctx).Infof("trying next host - response was %s", resp.Status) log.G(ctx).Infof("trying next host - response was %s", resp.Status)
continue // try another host continue // try another host
@ -384,8 +394,9 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
} }
// Prevent resolving to excessively large manifests // Prevent resolving to excessively large manifests
if size > MaxManifestSize { if size > MaxManifestSize {
if firstErr == nil { if firstErrPriority < 4 {
firstErr = fmt.Errorf("rejecting %d byte manifest for %s: %w", size, ref, errdefs.ErrNotFound) firstErr = fmt.Errorf("rejecting %d byte manifest for %s: %w", size, ref, errdefs.ErrNotFound)
firstErrPriority = 4
} }
continue continue
} }
@ -401,10 +412,8 @@ func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocisp
} }
} }
// If above loop terminates without return, then there was an error. // If above loop terminates without return or error, then no registries
// "firstErr" contains the first non-404 error. That is, "firstErr == nil" // were provided.
// means that either no registries were given or each registry returned 404.
if firstErr == nil { if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", ref, errdefs.ErrNotFound) firstErr = fmt.Errorf("%s: %w", ref, errdefs.ErrNotFound)
} }