fix(registry): image name parsing behavior (#1526)

Co-authored-by: nils måsén <nils@piksel.se>
pull/1516/head
Reinier van der Leer 1 year ago committed by GitHub
parent aa50d12389
commit 25fdb40312
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -23,19 +23,29 @@ password `auth` string:
``` ```
`<REGISTRY_NAME>` needs to be replaced by the name of your private registry `<REGISTRY_NAME>` needs to be replaced by the name of your private registry
(e.g., `my-private-registry.example.org`) (e.g., `my-private-registry.example.org`).
!!! important "Using private images on docker hub" !!! info "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`. To access private repositories on Docker Hub,
So instead of `<REGISTRY_NAME>` should be `https://index.docker.io/v1/`.
``` In this special case, the registry domain does not have to be specified
docker run -d myuser/myimage in `docker run` or `docker-compose`. Like Docker, Watchtower will use the
``` Docker Hub registry and its credentials when no registry domain is specified.
you would run it as
``` <sub>Watchtower will recognize credentials with `<REGISTRY_NAME>` `index.docker.io`,
docker run -d index.docker.io/myuser/myimage 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: 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" version: "3.4"
services: services:
watchtower: watchtower:
image: index.docker.io/containrrr/watchtower:latest image: containrrr/watchtower:latest
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- <PATH_TO_HOME_DIR>/.docker/config.json:/config.json - <PATH_TO_HOME_DIR>/.docker/config.json:/config.json

@ -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 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 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 from a private repo on the GitHub Registry and monitors it with watchtower. Note the command argument changing the interval
30s rather than the default 24 hours. to 30s rather than the default 24 hours.
```yaml ```yaml
version: "3" version: "3"
services: services:
cavo: cavo:
image: index.docker.io/<org>/<image>:<tag> image: ghcr.io/<org>/<image>:<tag>
ports: ports:
- "443:3443" - "443:3443"
- "80:3080" - "80:3080"

@ -4,14 +4,14 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"github.com/containrrr/watchtower/pkg/registry/helpers" "github.com/containrrr/watchtower/pkg/registry/helpers"
"github.com/containrrr/watchtower/pkg/types" "github.com/containrrr/watchtower/pkg/types"
"github.com/docker/distribution/reference" ref "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -20,13 +20,13 @@ const ChallengeHeader = "WWW-Authenticate"
// GetToken fetches a token for the registry hosting the provided image // GetToken fetches a token for the registry hosting the provided image
func GetToken(container types.Container, registryAuth string) (string, error) { func GetToken(container types.Container, registryAuth string) (string, error) {
var err error normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
var URL url.URL if err != nil {
if URL, err = GetChallengeURL(container.ImageName()); err != nil {
return "", err 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 var req *http.Request
if req, err = GetChallengeRequest(URL); err != nil { 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 return fmt.Sprintf("Basic %s", registryAuth), nil
} }
if strings.HasPrefix(challenge, "bearer") { if strings.HasPrefix(challenge, "bearer") {
return GetBearerHeader(challenge, container.ImageName(), registryAuth) return GetBearerHeader(challenge, normalizedRef, registryAuth)
} }
return "", errors.New("unsupported challenge type from registry") 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 // 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{} client := http.Client{}
if strings.Contains(img, ":") { authURL, err := GetAuthURL(challenge, imageRef)
img = strings.Split(img, ":")[0]
}
authURL, err := GetAuthURL(challenge, img)
if err != nil { if err != nil {
return "", err return "", err
@ -103,7 +100,7 @@ func GetBearerHeader(challenge string, img string, registryAuth string) (string,
return "", err return "", err
} }
body, _ := ioutil.ReadAll(authResponse.Body) body, _ := io.ReadAll(authResponse.Body)
tokenResponse := &types.TokenResponse{} tokenResponse := &types.TokenResponse{}
err = json.Unmarshal(body, 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 // 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) loweredChallenge := strings.ToLower(challenge)
raw := strings.TrimPrefix(loweredChallenge, "bearer") raw := strings.TrimPrefix(loweredChallenge, "bearer")
@ -141,53 +138,25 @@ func GetAuthURL(challenge string, img string) (*url.URL, error) {
q := authURL.Query() q := authURL.Query()
q.Add("service", values["service"]) q.Add("service", values["service"])
scopeImage := GetScopeFromImageName(img, values["service"]) scopeImage := ref.Path(imageRef)
scope := fmt.Sprintf("repository:%s:pull", scopeImage) 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) q.Add("scope", scope)
authURL.RawQuery = q.Encode() authURL.RawQuery = q.Encode()
return authURL, nil return authURL, nil
} }
// GetScopeFromImageName normalizes an image name for use as scope during auth and head requests // GetChallengeURL returns the URL to check auth requirements
func GetScopeFromImageName(img, svc string) string { // for access to a given image
parts := strings.Split(img, "/") func GetChallengeURL(imageRef ref.Named) url.URL {
host, _ := helpers.GetRegistryAddress(imageRef.Name())
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
}
URL := url.URL{ URL := url.URL{
Scheme: "https", Scheme: "https",
Host: host, Host: host,
Path: "/v2/", Path: "/v2/",
} }
return URL, nil return URL
} }

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@ -11,6 +12,7 @@ import (
"github.com/containrrr/watchtower/pkg/registry/auth" "github.com/containrrr/watchtower/pkg/registry/auth"
wtTypes "github.com/containrrr/watchtower/pkg/types" wtTypes "github.com/containrrr/watchtower/pkg/types"
ref "github.com/docker/distribution/reference"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -52,7 +54,7 @@ var _ = Describe("the auth module", func() {
mockCreated, mockCreated,
mockDigest) mockDigest)
When("getting an auth url", func() { Describe("GetToken", func() {
It("should parse the token from the response", It("should parse the token from the response",
SkipIfCredentialsEmpty(GHCRCredentials, func() { SkipIfCredentialsEmpty(GHCRCredentials, func() {
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password) creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
@ -61,73 +63,100 @@ var _ = Describe("the auth module", func() {
Expect(token).NotTo(Equal("")) Expect(token).NotTo(Equal(""))
}), }),
) )
})
Describe("GetAuthURL", func() {
It("should create a valid auth url object based on the challenge header supplied", 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{ expected := &url.URL{
Host: "ghcr.io", Host: "ghcr.io",
Scheme: "https", Scheme: "https",
Path: "/token", Path: "/token",
RawQuery: "scope=repository%3Acontainrrr%2Fwatchtower%3Apull&service=ghcr.io", 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(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"` When("given an invalid challenge header", func() {
res, err := auth.GetAuthURL(input, "containrrr/watchtower") It("should return an error", func() {
Expect(err).To(HaveOccurred()) challenge := `bearer realm="https://ghcr.io/token"`
Expect(res).To(BeNil()) 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() { 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",` 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(err).NotTo(HaveOccurred())
Expect(res).NotTo(BeNil()) Expect(res).NotTo(BeNil())
}) })
It("should not crash when a field without a value is recieved", func() { 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` 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(err).NotTo(HaveOccurred())
Expect(res).NotTo(BeNil()) 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() { 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/"} 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/"} 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/"} expected := url.URL{Host: "index.docker.io", Scheme: "https", Path: "/v2/"}
Expect(auth.GetChallengeURL("docker.io/containrrr/watchtower:latest")).To(Equal(expected)) imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest")
Expect(auth.GetChallengeURL("registry-1.docker.io/containrrr/watchtower:latest")).To(Equal(expected)) 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")) var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$")
Expect(auth.GetScopeFromImageName("watchtower", "registry-1.docker.io")).To(Equal("library/watchtower"))
}) func getScopeFromImageAuthURL(imageName string) string {
It("should not include vanity hosts\"", func() { normalizedRef, _ := ref.ParseNormalizedNamed(imageName)
Expect(auth.GetScopeFromImageName("docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower")) challenge := `bearer realm="https://dummy.host/token",service="dummy.host",scope="repository:user/image:pull"`
Expect(auth.GetScopeFromImageName("index.docker.io/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower")) URL, _ := auth.GetAuthURL(challenge, normalizedRef)
})
It("should not destroy three segment image names\"", func() { scope := URL.Query().Get("scope")
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "index.docker.io")).To(Equal("containrrr/watchtower")) Expect(scopeImageRegexp.Match(scope)).To(BeTrue())
Expect(auth.GetScopeFromImageName("piksel/containrrr/watchtower", "ghcr.io")).To(Equal("piksel/containrrr/watchtower")) return strings.Replace(scope[11:], ":pull", "", 1)
}) }
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"))
})
})
})

@ -1,36 +1,28 @@
package helpers package helpers
import ( import (
"fmt" "github.com/docker/distribution/reference"
url2 "net/url"
) )
// ConvertToHostname strips a url from everything but the hostname part // domains for Docker Hub, the default registry
func ConvertToHostname(url string) (string, string, error) { const (
urlWithSchema := fmt.Sprintf("x://%s", url) DefaultRegistryDomain = "docker.io"
u, err := url2.Parse(urlWithSchema) DefaultRegistryHost = "index.docker.io"
if err != nil { LegacyDefaultRegistryDomain = "index.docker.io"
return "", "", err )
}
hostName := u.Hostname()
port := u.Port()
return hostName, port, err
}
// NormalizeRegistry makes sure variations of DockerHubs registry // GetRegistryAddress parses an image name
func NormalizeRegistry(registry string) (string, error) { // and returns the address of the specified registry
hostName, port, err := ConvertToHostname(registry) func GetRegistryAddress(imageRef string) (string, error) {
normalizedRef, err := reference.ParseNormalizedNamed(imageRef)
if err != nil { if err != nil {
return "", err return "", err
} }
if hostName == "registry-1.docker.io" || hostName == "docker.io" { address := reference.Domain(normalizedRef)
hostName = "index.docker.io"
}
if port != "" { if address == DefaultRegistryDomain {
return fmt.Sprintf("%s:%s", hostName, port), nil address = DefaultRegistryHost
} }
return hostName, nil return address, nil
} }

@ -1,9 +1,10 @@
package helpers package helpers
import ( import (
"testing"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"testing"
) )
func TestHelpers(t *testing.T) { func TestHelpers(t *testing.T) {
@ -12,20 +13,25 @@ func TestHelpers(t *testing.T) {
} }
var _ = Describe("the helpers", func() { var _ = Describe("the helpers", func() {
Describe("GetRegistryAddress", func() {
When("converting an url to a hostname", func() { It("should return error if passed empty string", func() {
It("should return docker.io given docker.io/containrrr/watchtower:latest", func() { _, err := GetRegistryAddress("")
host, port, err := ConvertToHostname("docker.io/containrrr/watchtower:latest") Expect(err).To(HaveOccurred())
Expect(err).NotTo(HaveOccurred())
Expect(host).To(Equal("docker.io"))
Expect(port).To(BeEmpty())
}) })
}) It("should return index.docker.io for image refs with no explicit registry", func() {
When("normalizing the registry information", func() { Expect(GetRegistryAddress("watchtower")).To(Equal("index.docker.io"))
It("should return index.docker.io given docker.io", func() { Expect(GetRegistryAddress("containrrr/watchtower")).To(Equal("index.docker.io"))
out, err := NormalizeRegistry("docker.io/containrrr/watchtower:latest") })
Expect(err).NotTo(HaveOccurred()) It("should return index.docker.io for image refs with docker.io domain", func() {
Expect(out).To(Equal("index.docker.io")) 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"))
}) })
}) })
}) })

@ -1,42 +1,41 @@
package manifest package manifest
import ( import (
"errors"
"fmt" "fmt"
"github.com/containrrr/watchtower/pkg/registry/auth" url2 "net/url"
"github.com/containrrr/watchtower/pkg/registry/helpers" "github.com/containrrr/watchtower/pkg/registry/helpers"
"github.com/containrrr/watchtower/pkg/types" "github.com/containrrr/watchtower/pkg/types"
ref "github.com/docker/distribution/reference" ref "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
url2 "net/url"
"strings"
) )
// BuildManifestURL from raw image data // BuildManifestURL from raw image data
func BuildManifestURL(container types.Container) (string, error) { func BuildManifestURL(container types.Container) (string, error) {
normalizedRef, err := ref.ParseDockerRef(container.ImageName())
normalizedName, err := ref.ParseNormalizedNamed(container.ImageName())
if err != nil { if err != nil {
return "", err 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()) host, _ := helpers.GetRegistryAddress(normalizedTaggedRef.Name())
img, tag := ExtractImageAndTag(strings.TrimPrefix(container.ImageName(), host+"/")) img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag()
logrus.WithFields(logrus.Fields{ logrus.WithFields(logrus.Fields{
"image": img, "image": img,
"tag": tag, "tag": tag,
"normalized": normalizedName, "normalized": normalizedTaggedRef.Name(),
"host": host, "host": host,
}).Debug("Parsing image ref") }).Debug("Parsing image ref")
if err != nil { if err != nil {
return "", err return "", err
} }
img = auth.GetScopeFromImageName(img, host)
if !strings.Contains(img, "/") {
img = "library/" + img
}
url := url2.URL{ url := url2.URL{
Scheme: "https", Scheme: "https",
Host: host, Host: host,
@ -44,24 +43,3 @@ func BuildManifestURL(container types.Container) (string, error) {
} }
return url.String(), nil 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
}

@ -1,13 +1,14 @@
package manifest_test package manifest_test
import ( import (
"testing"
"time"
"github.com/containrrr/watchtower/internal/actions/mocks" "github.com/containrrr/watchtower/internal/actions/mocks"
"github.com/containrrr/watchtower/pkg/registry/manifest" "github.com/containrrr/watchtower/pkg/registry/manifest"
apiTypes "github.com/docker/docker/api/types" apiTypes "github.com/docker/docker/api/types"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"testing"
"time"
) )
func TestManifest(t *testing.T) { func TestManifest(t *testing.T) {
@ -16,60 +17,58 @@ func TestManifest(t *testing.T) {
} }
var _ = Describe("the manifest module", func() { var _ = Describe("the manifest module", func() {
mockId := "mock-id" Describe("BuildManifestURL", func() {
mockName := "mock-container"
mockCreated := time.Now()
When("building a manifest url", func() {
It("should return a valid url given a fully qualified image", func() { It("should return a valid url given a fully qualified image", func() {
expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/latest" imageRef := "ghcr.io/containrrr/watchtower:mytag"
imageInfo := apiTypes.ImageInspect{ expected := "https://ghcr.io/v2/containrrr/watchtower/manifests/mytag"
RepoTags: []string{
"ghcr.io/k6io/operator:latest", URL, err := buildMockContainerManifestURL(imageRef)
},
}
mock := mocks.CreateMockContainerWithImageInfo(mockId, mockName, "ghcr.io/containrrr/watchtower:latest", mockCreated, imageInfo)
res, err := manifest.BuildManifestURL(mock)
Expect(err).NotTo(HaveOccurred()) 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" 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) URL, err := buildMockContainerManifestURL(imageRef)
res, err := manifest.BuildManifestURL(mock)
Expect(err).NotTo(HaveOccurred()) 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" 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) URL, err := buildMockContainerManifestURL(imageRef)
res, err := manifest.BuildManifestURL(mock)
Expect(err).NotTo(HaveOccurred()) 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() { It("should not prepend library/ for single-part container names in registries other than Docker Hub", func() {
in := "containrrr/watchtower:latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7" imageRef := "docker-registry.domain/imagename:latest"
image, tag := "containrrr/watchtower", "latest@sha256:daf7034c5c89775afe3008393ae033529913548243b84926931d7c84398ecda7" expected := "https://docker-registry.domain/v2/imagename/manifests/latest"
imageOut, tagOut := manifest.ExtractImageAndTag(in)
Expect(imageOut).To(Equal(image)) URL, err := buildMockContainerManifestURL(imageRef)
Expect(tagOut).To(Equal(tag)) 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)
}

@ -43,17 +43,17 @@ func DefaultAuthHandler() (string, error) {
// Will return false if behavior for container is unknown. // Will return false if behavior for container is unknown.
func WarnOnAPIConsumption(container watchtowerTypes.Container) bool { func WarnOnAPIConsumption(container watchtowerTypes.Container) bool {
normalizedName, err := ref.ParseNormalizedNamed(container.ImageName()) normalizedRef, err := ref.ParseNormalizedNamed(container.ImageName())
if err != nil { if err != nil {
return true return true
} }
containerHost, err := helpers.NormalizeRegistry(normalizedName.String()) containerHost, err := helpers.GetRegistryAddress(normalizedRef.Name())
if err != nil { if err != nil {
return true return true
} }
if containerHost == "index.docker.io" || containerHost == "ghcr.io" { if containerHost == helpers.DefaultRegistryHost || containerHost == "ghcr.io" {
return true return true
} }

@ -23,11 +23,9 @@ var _ = Describe("Registry", func() {
}) })
When("Given a container with an image explicitly from dockerhub", func() { When("Given a container with an image explicitly from dockerhub", func() {
It("should want to warn", 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("index.docker.io/docker:latest")).To(BeTrue())
Expect(testContainerWithImage("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() { When("Given a container with an image from some other registry", func() {
It("should not want to warn", func() { It("should not want to warn", func() {

@ -5,13 +5,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
"strings"
"github.com/containrrr/watchtower/pkg/registry/helpers"
cliconfig "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/config/credentials" "github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types" "github.com/docker/cli/cli/config/types"
"github.com/docker/distribution/reference"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -19,7 +18,7 @@ import (
// loaded from environment variables or docker config // loaded from environment variables or docker config
// as available in that order // as available in that order
func EncodedAuth(ref string) (string, error) { func EncodedAuth(ref string) (string, error) {
auth, err := EncodedEnvAuth(ref) auth, err := EncodedEnvAuth()
if err != nil { if err != nil {
auth, err = EncodedConfigAuth(ref) auth, err = EncodedConfigAuth(ref)
} }
@ -29,7 +28,7 @@ func EncodedAuth(ref string) (string, error) {
// EncodedEnvAuth returns an encoded auth config for the given registry // EncodedEnvAuth returns an encoded auth config for the given registry
// loaded from environment variables // loaded from environment variables
// Returns an error if authentication environment variables have not been set // 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") username := os.Getenv("REPO_USER")
password := os.Getenv("REPO_PASS") password := os.Getenv("REPO_PASS")
if username != "" && password != "" { if username != "" && password != "" {
@ -37,9 +36,11 @@ func EncodedEnvAuth(ref string) (string, error) {
Username: username, Username: username,
Password: password, 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 // CREDENTIAL: Uncomment to log REPO_PASS environment variable
// log.Tracef("Using auth password %s", auth.Password) // log.Tracef("Using auth password %s", auth.Password)
return EncodeAuth(auth) return EncodeAuth(auth)
} }
return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set") 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 // loaded from the docker config
// Returns an empty string if credentials cannot be found for the referenced server // Returns an empty string if credentials cannot be found for the referenced server
// The docker config must be mounted on the container // The docker config must be mounted on the container
func EncodedConfigAuth(ref string) (string, error) { func EncodedConfigAuth(imageRef string) (string, error) {
server, err := ParseServerAddress(ref) server, err := helpers.GetRegistryAddress(imageRef)
if err != nil { 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 return "", err
} }
configDir := os.Getenv("DOCKER_CONFIG") configDir := os.Getenv("DOCKER_CONFIG")
if configDir == "" { if configDir == "" {
configDir = "/" configDir = "/"
} }
configFile, err := cliconfig.Load(configDir) configFile, err := cliconfig.Load(configDir)
if err != nil { 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 return "", err
} }
credStore := CredentialsStore(*configFile) 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) log.WithField("config_file", configFile.Filename).Debugf("No credentials for %s found", server)
return "", nil 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 // CREDENTIAL: Uncomment to log docker config password
// log.Tracef("Using auth password %s", auth.Password) // log.Tracef("Using auth password %s", auth.Password)
return EncodeAuth(auth) 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 // CredentialsStore returns a new credentials store based
// on the settings provided in the configuration file. // on the settings provided in the configuration file.
func CredentialsStore(configFile configfile.ConfigFile) credentials.Store { func CredentialsStore(configFile configfile.ConfigFile) credentials.Store {

@ -1,65 +1,49 @@
package registry package registry
import ( import (
"os"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"os"
) )
var _ = Describe("Testing with Ginkgo", func() { var _ = Describe("Registry credential helpers", func() {
It("encoded env auth_ should return an error if repo envs are unset", func() { Describe("EncodedAuth", func() {
_ = os.Unsetenv("REPO_USER") It("should return repo credentials from env when set", func() {
_ = os.Unsetenv("REPO_PASS") var err error
expected := "eyJ1c2VybmFtZSI6ImNvbnRhaW5ycnItdXNlciIsInBhc3N3b3JkIjoiY29udGFpbnJyci1wYXNzIn0="
_, 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="
err = os.Setenv("REPO_USER", "containrrr-user") err = os.Setenv("REPO_USER", "containrrr-user")
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
err = os.Setenv("REPO_PASS", "containrrr-pass") err = os.Setenv("REPO_PASS", "containrrr-pass")
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
config, err := EncodedEnvAuth("") config, err := EncodedEnvAuth()
Expect(config).To(Equal(expectedHash)) Expect(config).To(Equal(expected))
Expect(err).NotTo(HaveOccurred()) 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("") Describe("EncodedEnvAuth", func() {
Expect(err).To(HaveOccurred()) 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("") Describe("EncodedConfigAuth", func() {
Expect(err).To(HaveOccurred()) It("should return an error if file is not present", func() {
}) var err error
It("parse server address_ should return the organization part if passed an image name missing server name", func() {
val, _ := ParseServerAddress("containrrr/config") err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail")
Expect(val).To(Equal("containrrr")) Expect(err).NotTo(HaveOccurred())
})
It("parse server address_ should return the server name if passed a fully qualified image name", func() {
val, _ := ParseServerAddress("github.com/containrrrr/config") _, err = EncodedConfigAuth("")
Expect(val).To(Equal("github.com")) Expect(err).To(HaveOccurred())
})
}) })
}) })

Loading…
Cancel
Save