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: ``` `` 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" - 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 - ``` +!!! info "Using private images on Docker Hub" + To access private repositories on Docker Hub, + `` 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. + + Watchtower will recognize credentials with `` `index.docker.io`, + but the Docker CLI will not. +!!! 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 - /.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//: + image: ghcr.io//: 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/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)) - }) - }) - 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")) - - }) - 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")) + imageRef, _ := ref.ParseNormalizedNamed("docker.io/containrrr/watchtower:latest") + Expect(auth.GetChallengeURL(imageRef)).To(Equal(expected)) }) }) }) + +var scopeImageRegexp = MatchRegexp("^repository:[a-z0-9]+(/[a-z0-9]+)*:pull$") + +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.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() +// domains for Docker Hub, the default registry +const ( + DefaultRegistryDomain = "docker.io" + DefaultRegistryHost = "index.docker.io" + LegacyDefaultRegistryDomain = "index.docker.io" +) - return hostName, port, err -} - -// 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/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.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/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" + 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" - imageOut, tagOut := manifest.ExtractImageAndTag(in) - - 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.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/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.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 { 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") +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 := EncodedEnvAuth("") - Expect(err).To(HaveOccurred()) + err = os.Setenv("REPO_USER", "containrrr-user") + Expect(err).NotTo(HaveOccurred()) + + err = os.Setenv("REPO_PASS", "containrrr-pass") + Expect(err).NotTo(HaveOccurred()) + + config, err := EncodedEnvAuth() + Expect(config).To(Equal(expected)) + Expect(err).NotTo(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") - Expect(err).NotTo(HaveOccurred()) + Describe("EncodedEnvAuth", func() { + It("should return an error if repo envs are unset", func() { + _ = os.Unsetenv("REPO_USER") + _ = os.Unsetenv("REPO_PASS") - err = os.Setenv("REPO_PASS", "containrrr-pass") - Expect(err).NotTo(HaveOccurred()) - - config, err := EncodedEnvAuth("") - Expect(config).To(Equal(expectedHash)) - Expect(err).NotTo(HaveOccurred()) + _, err := EncodedEnvAuth() + Expect(err).To(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()) + Describe("EncodedConfigAuth", func() { + It("should return an error if file is not present", func() { + var err error - _, err = EncodedConfigAuth("") - Expect(err).To(HaveOccurred()) + err = os.Setenv("DOCKER_CONFIG", "/dev/null/should-fail") + Expect(err).NotTo(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() { - - 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() { - - val, _ := ParseServerAddress("github.com/containrrrr/config") - Expect(val).To(Equal("github.com")) + _, err = EncodedConfigAuth("") + Expect(err).To(HaveOccurred()) + }) }) })