Repository: containrrr/watchtower (go)

Issue:
Cannot do a head request for private registry
### Describe the bug

I am having the exact same issue as described and discussed in https://github.com/containrrr/watchtower/issues/715, with the latest version of the image and the latest `registry:2` image.

My registry is located at `docker-registry.sub.domain.nl`, image names are single part, so `docker-registry.sub.domain.nl/imagename:latest`.

Watchtower tries `HEAD /v2/library/imagename/manifests/latest`. This fails, after which it "falls back to regular pull" and requests `HEAD /v2/imagename/manifests/latest`, which succeeds.


### Steps to reproduce

Have watchtower try to pull an image from a private registry.

### Expected behavior

`HEAD /v2/imagename/manifests/latest` -> no unnecessary pulls

### Screenshots

_No response_

### Environment

* Docker 20.10.11
* `containrrr/watchtower:latest` (latest version)
* `registry:2` (latest version)

### Your logs

```text
msg="Trying to load authentication credentials." container=/container image="docker-registry.sub.domain.nl/imagename:latest"
msg="Loaded auth credentials for user deploy, on registry docker-registry.sub.domain.nl/imagename:latest, from file /config.json"
msg="Got image name: docker-registry.sub.domain.nl/imagename:latest"
msg="Credentials loaded"
msg="Checking if pull is needed" container=/container image="docker-registry.sub.domain.nl/imagename:latest"
msg="Building challenge URL" URL="https://docker-registry.sub.domain.nl/v2/"
msg="Got response to challenge request" header="Basic realm=\"Registry Realm\"" status="401 Unauthorized"
msg="Parsing image ref" host=docker-registry.sub.domain.nl image=imagename normalized="docker-registry.sub.domain.nl/imagename:latest" tag=latest
msg="Doing a HEAD request to fetch a digest" url="https://docker-registry.sub.domain.nl/v2/library/login/manifests/latest"
msg="Could not do a head request for \"docker-registry.sub.domain.nl/imagename:latest\", falling back to regular pull." container=/container image="docker-registry.sub.domain.nl/imagename:latest"
msg="Reason: registry responded to head request with \"404 Not Found\", auth: \"not present\"" container=/container image="docker-registry.sub.domain.nl/imagename:latest"
msg="Pulling image" container=/container image="docker-registry.sub.domain.nl/imagename:latest"
msg="No new images found for /container"
```


### Additional context

_No response_

Pull request:
fix(registry): image name parsing behavior
## What this PR contributes
It replaces Watchtower's own image ref manipulation logic (wherever possible) by docker's own implementations as imported from [`docker/distribution/reference`](https://pkg.go.dev/github.com/docker/distribution@v2.8.1+incompatible/reference).

As a result, this PR:
* solves failing HEAD requests for images with a single-part path
* solves other off-spec behavior in some cases (such as references where the registry host has a port)
* reduces the amount of code to be maintained in this repository
* reduces the complexity of some parts of the code
* resolves [this](https://github.com/containrrr/watchtower/blob/a4d00bfd7589d45c8e3bf42d8d6d2dc0bbf133d4/pkg/registry/trust_test.go#L42-L48) todo
* Closes #1525

Changes are limited to *pkg/registry*, and deprecate *pkg/registry/helpers*.

<details>
<summary>How/what/why this works</summary>

In [reference/normalize.go], `ParseNormalizedNamed` and `ParseDockerRef` call `splitDockerDomain` which normalizes the registry domain from *index.docker.io* to *docker.io* if applicable. This allows us to directly use the properties of the `Named`/`NamedTagged`/`NamedDigested` that is returned by these (and other) functions.
* `helpers.NormalizeRegistry(normalizedName.String())` -> `reference.Domain(normalizedName)`
* `ExtractImageAndTag([...])` -> `reference.Path(normalizedRef), reference.Tag(normalizedRef)` (if container image is not pinned)
* `auth.GetScopeFromImageName(img, host)` -> `reference.Path(normalizedName)`
* etc...
</details>

[reference/normalize.go]: https://github.com/distribution/distribution/blob/362910506bc213e9bfc3e3e8999e0cfc757d34ba/reference/normalize.go

### Notable choices made along the way
* Defaulting to `docker.io` credentials (stored in the config under `index.docker.io` or `https://index.docker.io/v1/`) when an image name does not contain a registry host. This was an outstanding [todo](https://github.com/containrrr/watchtower/blob/a4d00bfd7589d45c8e3bf42d8d6d2dc0bbf133d4/pkg/registry/trust_test.go#L42-L48), and can be done safely [under the right conditions](#issuecomment-1386874300).
* Using `index.docker.io` to specify an image on Docker Hub is legacy, so I have replaced `index.docker.io` with `docker.io` in all relevant places, or removed it altogether, as with this PR, Watchtower should use the right Docker Hub domain by default when no registry is specified in an image name.
* `registry-1.docker.io` is only supposed to be used in setting up a pull-through cache registry, not for pulling images directly. Hence, I think it's reasonable to drop ["special support"](https://github.com/containrrr/watchtower/blob/a4d00bfd7589d45c8e3bf42d8d6d2dc0bbf133d4/pkg/registry/helpers/helpers.go#L28) for this case (and disable the corresponding test).
If anyone really wants to use that domain, they can still specify the image as `registry-1.docker.io/library/image`, just like they already need to do when using a docker hub mirror.
* Disabling [this test](https://github.com/containrrr/watchtower/blob/a4d00bfd7589d45c8e3bf42d8d6d2dc0bbf133d4/pkg/registry/auth/auth_test.go#L117), which tests for something that should never occur: a container with a familiar name (e.g. `ubuntu` for `docker.io/library/ubuntu`) that is not hosted on Docker Hub. Containers pulled from registries other than Docker Hub will always have the registry domain in `container.ImageName()`.

## Tests
After my updates, all tests succeed except for the two mentioned above, which I have commented for now.

I tried to improve test coverage as best as possible for the files that I changed, including a test in [pkg/registry/manifest/manifest_test.go](https://github.com/Pwuts/watchtower/blob/fix/ref-processing/pkg/registry/manifest/manifest_test.go#L65) for the specific issue that triggered me to do this work :)

## Updates to the documentation
As mentioned above, I have replaced `index.docker.io` by `docker.io` wherever relevant and appropriate.
Also added a heads-up + examples of using private registries on local and remote hosts.

Patch:
diff --git a/.github/workflows/dependabot-approve.yml b/.github/workflows/dependabot-approve.yml
new file mode 100644
index 0000000..46f9d18
--- /dev/null
+++ b/.github/workflows/dependabot-approve.yml
@@ -0,0 +1,12 @@
+name: Auto approve dependabot PRs
+
+on: pull_request_target
+
+jobs:
+  auto-approve:
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: write
+    if: github.actor == 'dependabot[bot]'
+    steps:
+      - uses: hmarr/auto-approve-action@v3
diff --git a/docs/metrics.md b/docs/metrics.md
index 7bb6383..480d7c6 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -4,7 +4,7 @@
 
 Metrics can be used to track how Watchtower behaves over time.
 
-To use this feature, you have to set an [API token](arguments.md#http-api-token) and [enable the metrics API](arguments.md#http-api-metrics),
+To use this feature, you have to set an [API token](arguments.md#http_api_token) and [enable the metrics API](arguments.md#http_api_metrics),
 as well as creating a port mapping for your container for port `8080`.
 
 The metrics API endpoint is `/v1/metrics`.
diff --git a/docs/private-registries.md b/docs/private-registries.md
index ee4ed41..1852795 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -23,19 +23,29 @@ password `auth` string:
 ```
 
 `<REGISTRY_NAME>` needs to be replaced by the name of your private registry
-(e.g., `my-private-registry.example.org`)
-
-!!! important "Using private images on docker hub"
-    When using private images on docker hub, the containers beeing watched needs to use the full image name, including the repository prefix `index.docker.io`.
-    So instead of
-    ```
-    docker run -d myuser/myimage
-    ```
-    you would run it as
-    ```
-    docker run -d index.docker.io/myuser/myimage
-    ```
-
+(e.g., `my-private-registry.example.org`).
+
+!!! info "Using private images on Docker Hub"
+    To access private repositories on Docker Hub,
+    `<REGISTRY_NAME>` should be `https://index.docker.io/v1/`.
+    In this special case, the registry domain does not have to be specified
+    in `docker run` or `docker-compose`. Like Docker, Watchtower will use the
+    Docker Hub registry and its credentials when no registry domain is specified.
+    
+    <sub>Watchtower will recognize credentials with `<REGISTRY_NAME>` `index.docker.io`,
+    but the Docker CLI will not.</sub>
+
+!!! important "Using a private registry on a local host"
+    To use a private registry hosted locally, make sure to correctly specify the registry host
+    in both `config.json` and the `docker run` command or `docker-compose` file.
+    Valid hosts are `localhost[:PORT]`, `HOST:PORT`,
+    or any multi-part `domain.name` or IP-address with or without a port.
+    
+    Examples:
+    * `localhost` -> `localhost/myimage`
+    * `127.0.0.1` -> `127.0.0.1/myimage:mytag`
+    * `host.domain` -> `host.domain/myorganization/myimage`
+    * `other-lan-host:80` -> `other-lan-host:80/imagename:latest`
 
 The required `auth` string can be generated as follows:
 
@@ -75,7 +85,7 @@ When creating the watchtower container via docker-compose, use the following lin
 version: "3.4"
 services:
   watchtower:
-    image: index.docker.io/containrrr/watchtower:latest
+    image: containrrr/watchtower:latest
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock
       - <PATH_TO_HOME_DIR>/.docker/config.json:/config.json
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index 8c1e12f..1cac352 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -48,14 +48,14 @@ docker run -d \
 
 If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your
 watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container
-from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to
-30s rather than the default 24 hours.
+from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval
+to 30s rather than the default 24 hours.
 
 ```yaml
 version: "3"
 services:
   cavo:
-    image: index.docker.io/<org>/<image>:<tag>
+    image: ghcr.io/<org>/<image>:<tag>
     ports:
       - "443:3443"
       - "80:3080"
diff --git a/pkg/registry/auth/auth.go b/pkg/registry/auth/auth.go
index b21e759..aae4df4 100644
--- a/pkg/registry/auth/auth.go
+++ b/pkg/registry/auth/auth.go
@@ -4,14 +4,14 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/url"
 	"strings"
 
 	"github.com/containrrr/watchtower/pkg/registry/helpers"
 	"github.com/containrrr/watchtower/pkg/types"
-	"github.com/docker/distribution/reference"
+	ref "github.com/docker/distribution/reference"
 	"github.com/sirupsen/logrus"
 )
 
@@ -20,13 +20,13 @@ const ChallengeHeader = "WWW-Authenticate"
 
 // GetToken fetches a token for the registry hosting the provided image
 func GetToken(container types.Container, registryAuth string) (string, error) {
-	var err error
-	var URL url.URL
-
-	if URL, err = GetChallengeURL(container.ImageName()); err != nil {
+	normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
+	if err != nil {
 		return "", err
 	}
-	logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
+
+	URL := GetChallengeURL(normalizedRef)
+	logrus.WithField("URL", URL.String()).Debug("Built challenge URL")
 
 	var req *http.Request
 	if req, err = GetChallengeRequest(URL); err != nil {
@@ -55,7 +55,7 @@ func GetToken(container types.Container, registryAuth string) (string, error) {
 		return fmt.Sprintf("Basic %s", registryAuth), nil
 	}
 	if strings.HasPrefix(challenge, "bearer") {
-		return GetBearerHeader(challenge, container.ImageName(), registryAuth)
+		return GetBearerHeader(challenge, normalizedRef, registryAuth)
 	}
 
 	return "", errors.New("unsupported challenge type from registry")
@@ -73,12 +73,9 @@ func GetChallengeRequest(URL url.URL) (*http.Request, error) {
 }
 
 // GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
-func GetBearerHeader(challenge string, img string, registryAuth string) (string, error) {
+func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) {
 	client := http.Client{}
-	if strings.Contains(img, ":") {
-		img = strings.Split(img, ":")[0]
-	}
-	authURL, err := GetAuthURL(challenge, img)
+	authURL, err := GetAuthURL(challenge, imageRef)
 
 	if err != nil {
 		return "", err
@@ -103,7 +100,7 @@ func GetBearerHeader(challenge string, img string, registryAuth string) (string,
 		return "", err
 	}
 
-	body, _ := ioutil.ReadAll(authResponse.Body)
+	body, _ := io.ReadAll(authResponse.Body)
 	tokenResponse := &types.TokenResponse{}
 
 	err = json.Unmarshal(body, tokenResponse)
@@ -115,7 +112,7 @@ func GetBearerHeader(challenge string, img string, registryAuth string) (string,
 }
 
 // GetAuthURL from the instructions in the challenge
-func GetAuthURL(challenge string, img string) (*url.URL, error) {
+func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) {
 	loweredChallenge := strings.ToLower(challenge)
 	raw := strings.TrimPrefix(loweredChallenge, "bearer")
 
@@ -141,53 +138,25 @@ func GetAuthURL(challenge string, img string) (*url.URL, error) {
 	q := authURL.Query()
 	q.Add("service", values["service"])
 
-	scopeImage := GetScopeFromImageName(img, values["service"])
+	scopeImage := ref.Path(imageRef)
 
 	scope := fmt.Sprintf("repository:%s:pull", scopeImage)
-	logrus.WithFields(logrus.Fields{"scope": scope, "image": img}).Debug("Setting scope for auth token")
+	logrus.WithFields(logrus.Fields{"scope": scope, "image": imageRef.Name()}).Debug("Setting scope for auth token")
 	q.Add("scope", scope)
 
 	authURL.RawQuery = q.Encode()
 	return authURL, nil
 }
 
-// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests
-func GetScopeFromImageName(img, svc string) string {
-	parts := strings.Split(img, "/")
-
-	if len(parts) > 2 {
-		if strings.Contains(svc, "docker.io") {
-			return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/"))
-		}
-		return strings.Join(parts, "/")
-	}
-
-	if len(parts) == 2 {
-		if strings.Contains(parts[0], "docker.io") {
-			return fmt.Sprintf("library/%s", parts[1])
-		}
-		return strings.Replace(img, svc+"/", "", 1)
-	}
-
-	if strings.Contains(svc, "docker.io") {
-		return fmt.Sprintf("library/%s", parts[0])
-	}
-	return img
-}
-
-// GetChallengeURL creates a URL object based on the image info
-func GetChallengeURL(img string) (url.URL, error) {
-
-	normalizedNamed, _ := reference.ParseNormalizedNamed(img)
-	host, err := helpers.NormalizeRegistry(normalizedNamed.String())
-	if err != nil {
-		return url.URL{}, err
-	}
+// GetChallengeURL returns the URL to check auth requirements
+// for access to a given image
+func GetChallengeURL(imageRef ref.Named) url.URL {
+	host, _ := helpers.GetRegistryAddress(imageRef.Name())
 
 	URL := url.URL{
 		Scheme: "https",
 		Host:   host,
 		Path:   "/v2/",
 	}
-	return URL, nil
+	return URL
 }
diff --git a/pkg/registry/helpers/helpers.go b/pkg/registry/helpers/helpers.go
index 1469331..8d99f2d 100644
--- a/pkg/registry/helpers/helpers.go
+++ b/pkg/registry/helpers/helpers.go
@@ -1,36 +1,28 @@
 package helpers
 
 import (
-	"fmt"
-	url2 "net/url"
+	"github.com/docker/distribution/reference"
 )
 
-// ConvertToHostname strips a url from everything but the hostname part
-func ConvertToHostname(url string) (string, string, error) {
-	urlWithSchema := fmt.Sprintf("x://%s", url)
-	u, err := url2.Parse(urlWithSchema)
-	if err != nil {
-		return "", "", err
-	}
-	hostName := u.Hostname()
-	port := u.Port()
-
-	return hostName, port, err
-}
+// domains for Docker Hub, the default registry
+const (
+	DefaultRegistryDomain       = "docker.io"
+	DefaultRegistryHost         = "index.docker.io"
+	LegacyDefaultRegistryDomain = "index.docker.io"
+)
 
-// NormalizeRegistry makes sure variations of DockerHubs registry
-func NormalizeRegistry(registry string) (string, error) {
-	hostName, port, err := ConvertToHostname(registry)
+// GetRegistryAddress parses an image name
+// and returns the address of the specified registry
+func GetRegistryAddress(imageRef string) (string, error) {
+	normalizedRef, err := reference.ParseNormalizedNamed(imageRef)
 	if err != nil {
 		return "", err
 	}
 
-	if hostName == "registry-1.docker.io" || hostName == "docker.io" {
-		hostName = "index.docker.io"
-	}
+	address := reference.Domain(normalizedRef)
 
-	if port != "" {
-		return fmt.Sprintf("%s:%s", hostName, port), nil
+	if address == DefaultRegistryDomain {
+		address = DefaultRegistryHost
 	}
-	return hostName, nil
+	return address, nil
 }
diff --git a/pkg/registry/manifest/manifest.go b/pkg/registry/manifest/manifest.go
index facbb6c..d1b18a9 100644
--- a/pkg/registry/manifest/manifest.go
+++ b/pkg/registry/manifest/manifest.go
@@ -1,42 +1,41 @@
 package manifest
 
 import (
+	"errors"
 	"fmt"
-	"github.com/containrrr/watchtower/pkg/registry/auth"
+	url2 "net/url"
+
 	"github.com/containrrr/watchtower/pkg/registry/helpers"
 	"github.com/containrrr/watchtower/pkg/types"
 	ref "github.com/docker/distribution/reference"
 	"github.com/sirupsen/logrus"
-	url2 "net/url"
-	"strings"
 )
 
 // BuildManifestURL from raw image data
 func BuildManifestURL(container types.Container) (string, error) {
-
-	normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
+	normalizedRef, err := ref.ParseDockerRef(container.ImageName())
 	if err != nil {
 		return "", err
 	}
+	normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged)
+	if !isTagged {
+		return "", errors.New("Parsed container image ref has no tag: " + normalizedRef.String())
+	}
 
-	host, err := helpers.NormalizeRegistry(normalizedName.String())
-	img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/"))
+	host, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name())
+	img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag()
 
 	logrus.WithFields(logrus.Fields{
 		"image":      img,
 		"tag":        tag,
-		"normalized": normalizedName,
+		"normalized": normalizedTaggedRef.Name(),
 		"host":       host,
 	}).Debug("Parsing image ref")
 
 	if err != nil {
 		return "", err
 	}
-	img = auth.GetScopeFromImageName(img, host)
 
-	if !strings.Contains(img, "/") {
-		img = "library/" + img
-	}
 	url := url2.URL{
 		Scheme: "https",
 		Host:   host,
@@ -44,24 +43,3 @@ func BuildManifestURL(container types.Container) (string, error) {
 	}
 	return url.String(), nil
 }
-
-// ExtractImageAndTag from a concatenated string
-func ExtractImageAndTag(imageName string) (string, string) {
-	var img string
-	var tag string
-
-	if strings.Contains(imageName, ":") {
-		parts := strings.Split(imageName, ":")
-		if len(parts) > 2 {
-			img = parts[0]
-			tag = strings.Join(parts[1:], ":")
-		} else {
-			img = parts[0]
-			tag = parts[1]
-		}
-	} else {
-		img = imageName
-		tag = "latest"
-	}
-	return img, tag
-}
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index 0347673..4894f04 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -43,17 +43,17 @@ func DefaultAuthHandler() (string, error) {
 // Will return false if behavior for container is unknown.
 func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
 
-	normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
+	normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
 	if err != nil {
 		return true
 	}
 
-	containerHost, err := helpers.NormalizeRegistry(normalizedName.String())
+	containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name())
 	if err != nil {
 		return true
 	}
 
-	if containerHost == "index.docker.io" || containerHost == "ghcr.io" {
+	if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" {
 		return true
 	}
 
diff --git a/pkg/registry/trust.go b/pkg/registry/trust.go
index 9024777..0b20248 100644
--- a/pkg/registry/trust.go
+++ b/pkg/registry/trust.go
@@ -5,13 +5,12 @@ import (
 	"encoding/json"
 	"errors"
 	"os"
-	"strings"
 
+	"github.com/containrrr/watchtower/pkg/registry/helpers"
 	cliconfig "github.com/docker/cli/cli/config"
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/docker/cli/cli/config/credentials"
 	"github.com/docker/cli/cli/config/types"
-	"github.com/docker/distribution/reference"
 	log "github.com/sirupsen/logrus"
 )
 
@@ -19,7 +18,7 @@ import (
 // loaded from environment variables or docker config
 // as available in that order
 func EncodedAuth(ref string) (string, error) {
-	auth, err := EncodedEnvAuth(ref)
+	auth, err := EncodedEnvAuth()
 	if err != nil {
 		auth, err = EncodedConfigAuth(ref)
 	}
@@ -29,7 +28,7 @@ func EncodedAuth(ref string) (string, error) {
 // EncodedEnvAuth returns an encoded auth config for the given registry
 // loaded from environment variables
 // Returns an error if authentication environment variables have not been set
-func EncodedEnvAuth(ref string) (string, error) {
+func EncodedEnvAuth() (string, error) {
 	username := os.Getenv("REPO_USER")
 	password := os.Getenv("REPO_PASS")
 	if username != "" && password != "" {
@@ -37,9 +36,11 @@ func EncodedEnvAuth(ref string) (string, error) {
 			Username: username,
 			Password: password,
 		}
-		log.Debugf("Loaded auth credentials for user %s on registry %s", auth.Username, ref)
+    
+		log.Debugf("Loaded auth credentials for registry user %s from environment", auth.Username)
 		// CREDENTIAL: Uncomment to log REPO_PASS environment variable
 		// log.Tracef("Using auth password %s", auth.Password)
+
 		return EncodeAuth(auth)
 	}
 	return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set")
@@ -49,19 +50,20 @@ func EncodedEnvAuth(ref string) (string, error) {
 // loaded from the docker config
 // Returns an empty string if credentials cannot be found for the referenced server
 // The docker config must be mounted on the container
-func EncodedConfigAuth(ref string) (string, error) {
-	server, err := ParseServerAddress(ref)
+func EncodedConfigAuth(imageRef string) (string, error) {
+	server, err := helpers.GetRegistryAddress(imageRef)
 	if err != nil {
-		log.Errorf("Unable to parse the image ref %s", err)
+		log.Errorf("Could not get registry from image ref %s", imageRef)
 		return "", err
 	}
+
 	configDir := os.Getenv("DOCKER_CONFIG")
 	if configDir == "" {
 		configDir = "/"
 	}
 	configFile, err := cliconfig.Load(configDir)
 	if err != nil {
-		log.Errorf("Unable to find default config file %s", err)
+		log.Errorf("Unable to find default config file: %s", err)
 		return "", err
 	}
 	credStore := CredentialsStore(*configFile)
@@ -71,24 +73,12 @@ func EncodedConfigAuth(ref string) (string, error) {
 		log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server)
 		return "", nil
 	}
-	log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, ref, configFile.Filename)
+	log.Debugf("Loaded auth credentials for user %s, on registry %s, from file %s", auth.Username, server, configFile.Filename)
 	// CREDENTIAL: Uncomment to log docker config password
 	// log.Tracef("Using auth password %s", auth.Password)
 	return EncodeAuth(auth)
 }
 
-// ParseServerAddress extracts the server part from a container image ref
-func ParseServerAddress(ref string) (string, error) {
-
-	parsedRef, err := reference.Parse(ref)
-	if err != nil {
-		return ref, err
-	}
-
-	parts := strings.Split(parsedRef.String(), "/")
-	return parts[0], nil
-}
-
 // CredentialsStore returns a new credentials store based
 // on the settings provided in the configuration file.
 func CredentialsStore(configFile configfile.ConfigFile) credentials.Store {


Test patch:
diff --git a/pkg/registry/auth/auth_test.go b/pkg/registry/auth/auth_test.go
index e276dda..f29ebff 100644
--- a/pkg/registry/auth/auth_test.go
+++ b/pkg/registry/auth/auth_test.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net/url"
 	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -11,6 +12,7 @@ import (
 	"github.com/containrrr/watchtower/pkg/registry/auth"
 
 	wtTypes "github.com/containrrr/watchtower/pkg/types"
+	ref "github.com/docker/distribution/reference"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 )
@@ -52,7 +54,7 @@ var _ = Describe("the auth module", func() {
 		mockCreated,
 		mockDigest)
 
-	When("getting an auth url", func() {
+	Describe("GetToken", func() {
 		It("should parse the token from the response",
 			SkipIfCredentialsEmpty(GHCRCredentials, func() {
 				creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
@@ -61,73 +63,100 @@ var _ = Describe("the auth module", func() {
 				Expect(token).NotTo(Equal(""))
 			}),
 		)
+	})
 
+	Describe("GetAuthURL", func() {
 		It("should create a valid auth url object based on the challenge header supplied", func() {
-			input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
+			challenge := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"`
+			imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+			Expect(err).NotTo(HaveOccurred())
 			expected := &url.URL{
 				Host:     "ghcr.io",
 				Scheme:   "https",
 				Path:     "/token",
 				RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io",
 			}
-			res, err := auth.GetAuthURL(input, "containrrr/watchtower")
+
+			URL, err := auth.GetAuthURL(challenge, imageRef)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(res).To(Equal(expected))
+			Expect(URL).To(Equal(expected))
 		})
-		It("should create a valid auth url object based on the challenge header supplied", func() {
-			input := `bearer realm="https://ghcr.io/token"`
-			res, err := auth.GetAuthURL(input, "containrrr/watchtower")
-			Expect(err).To(HaveOccurred())
-			Expect(res).To(BeNil())
+
+		When("given an invalid challenge header", func() {
+			It("should return an error", func() {
+				challenge := `bearer realm="https://ghcr.io/token"`
+				imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+				Expect(err).NotTo(HaveOccurred())
+				URL, err := auth.GetAuthURL(challenge, imageRef)
+				Expect(err).To(HaveOccurred())
+				Expect(URL).To(BeNil())
+			})
+		})
+
+		When("deriving the auth scope from an image name", func() {
+			It("should prepend official dockerhub images with \"library/\"", func() {
+				Expect(getScopeFromImageAuthURL("registry")).To(Equal("library/registry"))
+				Expect(getScopeFromImageAuthURL("docker.io/registry")).To(Equal("library/registry"))
+				Expect(getScopeFromImageAuthURL("index.docker.io/registry")).To(Equal("library/registry"))
+			})
+			It("should not include vanity hosts\"", func() {
+				Expect(getScopeFromImageAuthURL("docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+				Expect(getScopeFromImageAuthURL("index.docker.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+			})
+			It("should not destroy three segment image names\"", func() {
+				Expect(getScopeFromImageAuthURL("piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
+				Expect(getScopeFromImageAuthURL("ghcr.io/piksel/containrrr/watchtower")).To(Equal("piksel/containrrr/watchtower"))
+			})
+			It("should not prepend library/ to image names if they're not on dockerhub", func() {
+				Expect(getScopeFromImageAuthURL("ghcr.io/watchtower")).To(Equal("watchtower"))
+				Expect(getScopeFromImageAuthURL("ghcr.io/containrrr/watchtower")).To(Equal("containrrr/watchtower"))
+			})
 		})
 		It("should not crash when an empty field is recieved", func() {
 			input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",`
-			res, err := auth.GetAuthURL(input, "containrrr/watchtower")
+			imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+			Expect(err).NotTo(HaveOccurred())
+			res, err := auth.GetAuthURL(input, imageRef)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(res).NotTo(BeNil())
 		})
 		It("should not crash when a field without a value is recieved", func() {
 			input := `bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull",valuelesskey`
-			res, err := auth.GetAuthURL(input, "containrrr/watchtower")
+			imageRef, err := ref.ParseNormalizedNamed("containrrr/watchtower")
+			Expect(err).NotTo(HaveOccurred())
+			res, err := auth.GetAuthURL(input, imageRef)
 			Expect(err).NotTo(HaveOccurred())
 			Expect(res).NotTo(BeNil())
 		})
 	})
-	When("getting a challenge url", func() {
+
+	Describe("GetChallengeURL", func() {
 		It("should create a valid challenge url object based on the image ref supplied", func() {
 			expected := url.URL{Host: "ghcr.io", Scheme: "https", Path: "/v2/"}
-			Expect(auth.GetChallengeURL("ghcr.io/containrrr/watchtower:latest")).To(Equal(expected))
+			imageRef, _ := ref.ParseNormalizedNamed("ghcr.io/containrrr/watchtower:latest")
+			Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
 		})
-		It("should assume dockerhub if the image ref is not fully qualified", func() {
+		It("should assume Docker Hub for image refs with no explicit registry", func() {
 			expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
-			Expect(auth.GetChallengeURL("containrrr/watchtower:latest")).To(Equal(expected))
+			imageRef, _ := ref.ParseNormalizedNamed("containrrr/watchtower:latest")
+			Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
 		})
-		It("should convert legacy dockerhub hostnames to index.docker.io", func() {
+		It("should use index.docker.io if the image ref specifies docker.io", func() {
 			expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
-			Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected))
-			Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected))
+			imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest")
+			Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected))
 		})
 	})
-	When("getting the auth scope from an image name", func() {
-		It("should prepend official dockerhub images with \"library/\"", func() {
-			Expect(auth.GetScopeFromImageName("docker.io/registry", "index.docker.io")).To(Equal("library/registry"))
-			Expect(auth.GetScopeFromImageName("docker.io/registry", "docker.io")).To(Equal("library/registry"))
+})
 
-			Expect(auth.GetScopeFromImageName("registry", "index.docker.io")).To(Equal("library/registry"))
-			Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
+var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$")
 
-		})
-		It("should not include vanity hosts\"", func() {
-			Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
-			Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
-		})
-		It("should not destroy three segment image names\"", func() {
-			Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower"))
-			Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower"))
-		})
-		It("should not add \"library/\" for one segment image names if they're not on dockerhub", func() {
-			Expect(auth.GetScopeFromImageName("ghcr.io/watchtower", "ghcr.io")).To(Equal("watchtower"))
-			Expect(auth.GetScopeFromImageName("watchtower", "ghcr.io")).To(Equal("watchtower"))
-		})
-	})
-})
+func getScopeFromImageAuthURL(imageName string) string {
+	normalizedRef, _ := ref.ParseNormalizedNamed(imageName)
+	challenge := `bearer realm="https://dummy.host/token",service="dummy.host",scope="repository:user/image:pull"`
+	URL, _ := auth.GetAuthURL(challenge, normalizedRef)
+
+	scope := URL.Query().Get("scope")
+	Expect(scopeImageRegexp.Match(scope)).To(BeTrue())
+	return strings.Replace(scope[11:], ":pull", "", 1)
+}
diff --git a/pkg/registry/helpers/helpers_test.go b/pkg/registry/helpers/helpers_test.go
index 92e9116..a561c2c 100644
--- a/pkg/registry/helpers/helpers_test.go
+++ b/pkg/registry/helpers/helpers_test.go
@@ -1,9 +1,10 @@
 package helpers
 
 import (
+	"testing"
+
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
-	"testing"
 )
 
 func TestHelpers(t *testing.T) {
@@ -12,20 +13,25 @@ func TestHelpers(t *testing.T) {
 }
 
 var _ = Describe("the helpers", func() {
-
-	When("converting an url to a hostname", func() {
-		It("should return docker.io given docker.io/containrrr/watchtower:latest", func() {
-			host, port, err := ConvertToHostname("docker.io/containrrr/watchtower:latest")
-			Expect(err).NotTo(HaveOccurred())
-			Expect(host).To(Equal("docker.io"))
-			Expect(port).To(BeEmpty())
+	Describe("GetRegistryAddress", func() {
+		It("should return error if passed empty string", func() {
+			_, err := GetRegistryAddress("")
+			Expect(err).To(HaveOccurred())
 		})
-	})
-	When("normalizing the registry information", func() {
-		It("should return index.docker.io given docker.io", func() {
-			out, err := NormalizeRegistry("docker.io/containrrr/watchtower:latest")
-			Expect(err).NotTo(HaveOccurred())
-			Expect(out).To(Equal("index.docker.io"))
+		It("should return index.docker.io for image refs with no explicit registry", func() {
+			Expect(GetRegistryAddress("watchtower")).To(Equal("index.docker.io"))
+			Expect(GetRegistryAddress("containrrr/watchtower")).To(Equal("index.docker.io"))
+		})
+		It("should return index.docker.io for image refs with docker.io domain", func() {
+			Expect(GetRegistryAddress("docker.io/watchtower")).To(Equal("index.docker.io"))
+			Expect(GetRegistryAddress("docker.io/containrrr/watchtower")).To(Equal("index.docker.io"))
+		})
+		It("should return the host if passed an image name containing a local host", func() {
+			Expect(GetRegistryAddress("henk:80/watchtower")).To(Equal("henk:80"))
+			Expect(GetRegistryAddress("localhost/watchtower")).To(Equal("localhost"))
+		})
+		It("should return the server address if passed a fully qualified image name", func() {
+			Expect(GetRegistryAddress("github.com/containrrr/config")).To(Equal("github.com"))
 		})
 	})
 })
diff --git a/pkg/registry/manifest/manifest_test.go b/pkg/registry/manifest/manifest_test.go
index 95f196b..b24d9bc 100644
--- a/pkg/registry/manifest/manifest_test.go
+++ b/pkg/registry/manifest/manifest_test.go
@@ -1,13 +1,14 @@
 package manifest_test
 
 import (
+	"testing"
+	"time"
+
 	"github.com/containrrr/watchtower/internal/actions/mocks"
 	"github.com/containrrr/watchtower/pkg/registry/manifest"
 	apiTypes "github.com/docker/docker/api/types"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
-	"testing"
-	"time"
 )
 
 func TestManifest(t *testing.T) {
@@ -16,60 +17,58 @@ func TestManifest(t *testing.T) {
 }
 
 var _ = Describe("the manifest module", func() {
-	mockId := "mock-id"
-	mockName := "mock-container"
-	mockCreated := time.Now()
-
-	When("building a manifest url", func() {
+	Describe("BuildManifestURL", func() {
 		It("should return a valid url given a fully qualified image", func() {
-			expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/latest"
-			imageInfo := apiTypes.ImageInspect{
-				RepoTags: []string{
-					"ghcr.io/k6io/operator:latest",
-				},
-			}
-			mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "ghcr.io/containrrr/watchtower:latest", mockCreated, imageInfo)
-			res, err := manifest.BuildManifestURL(mock)
+			imageRef := "ghcr.io/containrrr/watchtower:mytag"
+			expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag"
+
+			URL, err := buildMockContainerManifestURL(imageRef)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(res).To(Equal(expected))
+			Expect(URL).To(Equal(expected))
 		})
-		It("should assume dockerhub for non-qualified images", func() {
+		It("should assume Docker Hub for image refs with no explicit registry", func() {
+			imageRef := "containrrr/watchtower:latest"
 			expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
-			imageInfo := apiTypes.ImageInspect{
-				RepoTags: []string{
-					"containrrr/watchtower:latest",
-				},
-			}
 
-			mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower:latest", mockCreated, imageInfo)
-			res, err := manifest.BuildManifestURL(mock)
+			URL, err := buildMockContainerManifestURL(imageRef)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(res).To(Equal(expected))
+			Expect(URL).To(Equal(expected))
 		})
-		It("should assume latest for images that lack an explicit tag", func() {
+		It("should assume latest for image refs with no explicit tag", func() {
+			imageRef := "containrrr/watchtower"
 			expected := "https://index.docker.io/v2/containrrr/watchtower/manifests/latest"
-			imageInfo := apiTypes.ImageInspect{
-
-				RepoTags: []string{
-					"containrrr/watchtower",
-				},
-			}
 
-			mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "containrrr/watchtower", mockCreated, imageInfo)
-
-			res, err := manifest.BuildManifestURL(mock)
+			URL, err := buildMockContainerManifestURL(imageRef)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(res).To(Equal(expected))
+			Expect(URL).To(Equal(expected))
 		})
-		It("should combine the tag name and digest pinning into one digest, given multiple colons", func() {
-			in := "containrrr/watchtower:latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
-			image, tag := "containrrr/watchtower", "latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
-
-			imageOut, tagOut := manifest.ExtractImageAndTag(in)
+		It("should not prepend library/ for single-part container names in registries other than Docker Hub", func() {
+			imageRef := "docker-registry.domain/imagename:latest"
+			expected := "https://docker-registry.domain/v2/imagename/manifests/latest"
 
-			Expect(imageOut).To(Equal(image))
-			Expect(tagOut).To(Equal(tag))
+			URL, err := buildMockContainerManifestURL(imageRef)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(URL).To(Equal(expected))
+		})
+		It("should throw an error on pinned images", func() {
+			imageRef := "docker-registry.domain/imagename@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7"
+			URL, err := buildMockContainerManifestURL(imageRef)
+			Expect(err).To(HaveOccurred())
+			Expect(URL).To(BeEmpty())
 		})
 	})
-
 })
+
+func buildMockContainerManifestURL(imageRef string) (string, error) {
+	imageInfo := apiTypes.ImageInspect{
+		RepoTags: []string{
+			imageRef,
+		},
+	}
+	mockID := "mock-id"
+	mockName := "mock-container"
+	mockCreated := time.Now()
+	mock := mocks.CreateMockContainerWithImageInfo(mockID, mockName, imageRef, mockCreated, imageInfo)
+
+	return manifest.BuildManifestURL(mock)
+}
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
index 5f3f57f..481c91d 100644
--- a/pkg/registry/registry_test.go
+++ b/pkg/registry/registry_test.go
@@ -23,11 +23,9 @@ var _ = Describe("Registry", func() {
 		})
 		When("Given a container with an image explicitly from dockerhub", func() {
 			It("should want to warn", func() {
-				Expect(testContainerWithImage("registry-1.docker.io/docker:latest")).To(BeTrue())
 				Expect(testContainerWithImage("index.docker.io/docker:latest")).To(BeTrue())
 				Expect(testContainerWithImage("docker.io/docker:latest")).To(BeTrue())
 			})
-
 		})
 		When("Given a container with an image from some other registry", func() {
 			It("should not want to warn", func() {
diff --git a/pkg/registry/trust_test.go b/pkg/registry/trust_test.go
index 3dab6ad..00fc8a7 100644
--- a/pkg/registry/trust_test.go
+++ b/pkg/registry/trust_test.go
@@ -1,65 +1,49 @@
 package registry
 
 import (
+	"os"
+
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
-	"os"
 )
 
-var _ = Describe("Testing with Ginkgo", func() {
-	It("encoded env auth_ should return an error if repo envs are unset", func() {
-		_ = os.Unsetenv("REPO_USER")
-		_ = os.Unsetenv("REPO_PASS")
-
-		_, err := EncodedEnvAuth("")
-		Expect(err).To(HaveOccurred())
-	})
-	It("encoded env auth_ should return auth hash if repo envs are set", func() {
-		var err error
-		expectedHash := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
+var _ = Describe("Registry credential helpers", func() {
+	Describe("EncodedAuth", func() {
+		It("should return repo credentials from env when set", func() {
+			var err error
+			expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
 
-		err = os.Setenv("REPO_USER", "containrrr-user")
-		Expect(err).NotTo(HaveOccurred())
+			err = os.Setenv("REPO_USER", "containrrr-user")
+			Expect(err).NotTo(HaveOccurred())
 
-		err = os.Setenv("REPO_PASS", "containrrr-pass")
-		Expect(err).NotTo(HaveOccurred())
+			err = os.Setenv("REPO_PASS", "containrrr-pass")
+			Expect(err).NotTo(HaveOccurred())
 
-		config, err := EncodedEnvAuth("")
-		Expect(config).To(Equal(expectedHash))
-		Expect(err).NotTo(HaveOccurred())
+			config, err := EncodedEnvAuth()
+			Expect(config).To(Equal(expected))
+			Expect(err).NotTo(HaveOccurred())
+		})
 	})
-	It("encoded config auth_ should return an error if file is not present", func() {
-		var err error
-
-		err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
-		Expect(err).NotTo(HaveOccurred())
 
-		_, err = EncodedConfigAuth("")
-		Expect(err).To(HaveOccurred())
+	Describe("EncodedEnvAuth", func() {
+		It("should return an error if repo envs are unset", func() {
+			_ = os.Unsetenv("REPO_USER")
+			_ = os.Unsetenv("REPO_PASS")
 
+			_, err := EncodedEnvAuth()
+			Expect(err).To(HaveOccurred())
+		})
 	})
-	/*
-	 * TODO:
-	 * This part only confirms that it still works in the same way as it did
-	 * with the old version of the docker api client sdk. I'd say that
-	 * ParseServerAddress likely needs to be elaborated a bit to default to
-	 * dockerhub in case no server address was provided.
-	 *
-	 * ++ @simskij, 2019-04-04
-	 */
-	It("parse server address_ should return error if passed empty string", func() {
 
-		_, err := ParseServerAddress("")
-		Expect(err).To(HaveOccurred())
-	})
-	It("parse server address_ should return the organization part if passed an image name missing server name", func() {
+	Describe("EncodedConfigAuth", func() {
+		It("should return an error if file is not present", func() {
+			var err error
 
-		val, _ := ParseServerAddress("containrrr/config")
-		Expect(val).To(Equal("containrrr"))
-	})
-	It("parse server address_ should return the server name if passed a fully qualified image name", func() {
+			err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
+			Expect(err).NotTo(HaveOccurred())
 
-		val, _ := ParseServerAddress("github.com/containrrrr/config")
-		Expect(val).To(Equal("github.com"))
+			_, err = EncodedConfigAuth("")
+			Expect(err).To(HaveOccurred())
+		})
 	})
 })