mirror of
https://github.com/containrrr/watchtower.git
synced 2025-12-16 23:20:12 +01:00
Merge 89a95a47ce into c16ac967c5
This commit is contained in:
commit
268941375d
10 changed files with 387 additions and 185 deletions
35
internal/testing/delayhttp/delayhttp.go
Normal file
35
internal/testing/delayhttp/delayhttp.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Package delayhttp creates http.HandlerFunc's that delays the response.
|
||||
// Useful for testing timeout scenarios.
|
||||
package delayhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WithChannel returns a handler that delays until it recieves something on returnChan
|
||||
func WithChannel(returnChan chan struct{}) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Wait until channel sends return code
|
||||
<-returnChan
|
||||
}
|
||||
}
|
||||
|
||||
// WithCancel returns a handler that delays until the cancel func is called.
|
||||
// Useful together with defer to clean up tests.
|
||||
func WithCancel() (http.HandlerFunc, func()) {
|
||||
returnChan := make(chan struct{}, 1)
|
||||
return WithChannel(returnChan), func() {
|
||||
returnChan <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout returns a handler that delays until the passed duration has elapsed
|
||||
func WithTimeout(delay time.Duration) http.HandlerFunc {
|
||||
returnChan := make(chan struct{}, 1)
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
returnChan <- struct{}{}
|
||||
}()
|
||||
return WithChannel(returnChan)
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/registry"
|
||||
"github.com/containrrr/watchtower/pkg/registry/digest"
|
||||
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
|
|
@ -52,6 +51,7 @@ func NewClient(opts ClientOptions) Client {
|
|||
return dockerClient{
|
||||
api: cli,
|
||||
ClientOptions: opts,
|
||||
reg: registry.NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +63,7 @@ type ClientOptions struct {
|
|||
ReviveStopped bool
|
||||
IncludeRestarting bool
|
||||
WarnOnHeadFailed WarningStrategy
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// WarningStrategy is a value determining when to show warnings
|
||||
|
|
@ -80,6 +81,16 @@ const (
|
|||
type dockerClient struct {
|
||||
api sdkClient.CommonAPIClient
|
||||
ClientOptions
|
||||
reg *registry.Client
|
||||
}
|
||||
|
||||
func (client *dockerClient) createContext() (context.Context, context.CancelFunc) {
|
||||
base := context.TODO()
|
||||
if client.ClientOptions.Timeout == 0 {
|
||||
// No timeout has been specified, let's not create a context that instantly cancels itself
|
||||
return base, func() {}
|
||||
}
|
||||
return context.WithTimeout(context.Background(), client.ClientOptions.Timeout)
|
||||
}
|
||||
|
||||
func (client dockerClient) WarnOnHeadPullFailed(container Container) bool {
|
||||
|
|
@ -278,7 +289,8 @@ func (client dockerClient) RenameContainer(c Container, newName string) error {
|
|||
}
|
||||
|
||||
func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, err error) {
|
||||
ctx := context.Background()
|
||||
ctx, cancel := client.createContext()
|
||||
defer cancel()
|
||||
|
||||
if !client.PullImages {
|
||||
log.Debugf("Skipping image pull.")
|
||||
|
|
@ -335,12 +347,12 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
|
|||
|
||||
log.WithFields(fields).Debugf("Checking if pull is needed")
|
||||
|
||||
if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil {
|
||||
if match, err := client.reg.CompareDigest(ctx, container, opts.RegistryAuth); err != nil {
|
||||
headLevel := log.DebugLevel
|
||||
if client.WarnOnHeadPullFailed(container) {
|
||||
headLevel = log.WarnLevel
|
||||
}
|
||||
log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
|
||||
log.WithFields(fields).Log(headLevel, "Could not do a head request, falling back to regular pull.")
|
||||
log.WithFields(fields).Log(headLevel, "Reason: ", err)
|
||||
} else if match {
|
||||
log.Debug("No pull needed. Skipping image.")
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ package container
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/containrrr/watchtower/internal/testing/delayhttp"
|
||||
"github.com/containrrr/watchtower/pkg/container/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/filters"
|
||||
t "github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/containrrr/watchtower/pkg/registry"
|
||||
wt "github.com/containrrr/watchtower/pkg/types"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/backend"
|
||||
|
|
@ -25,6 +27,7 @@ import (
|
|||
var _ = Describe("the client", func() {
|
||||
var docker *cli.Client
|
||||
var mockServer *ghttp.Server
|
||||
mockRegServer := ghttp.NewTLSServer()
|
||||
BeforeEach(func() {
|
||||
mockServer = ghttp.NewServer()
|
||||
docker, _ = cli.NewClientWithOpts(
|
||||
|
|
@ -33,6 +36,7 @@ var _ = Describe("the client", func() {
|
|||
})
|
||||
AfterEach(func() {
|
||||
mockServer.Close()
|
||||
mockRegServer.Reset()
|
||||
})
|
||||
Describe("WarnOnHeadPullFailed", func() {
|
||||
containerUnknown := *MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
|
||||
|
|
@ -103,6 +107,97 @@ var _ = Describe("the client", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
When("checking if container is stale", func() {
|
||||
|
||||
testRegPullFallback := func(client *dockerClient, cnt *Container, regResponses int) (bool, error) {
|
||||
delayedResponseHandler, cancel := delayhttp.WithCancel()
|
||||
defer cancel()
|
||||
|
||||
// TODO: Add mock handlers for repository requests
|
||||
regRequestHandlers := [][]http.HandlerFunc{
|
||||
{
|
||||
ghttp.VerifyRequest("GET", HaveSuffix("v2/")),
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
Fail("registry request is not implemented")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, regRequestPair := range regRequestHandlers {
|
||||
|
||||
verifyHandler := regRequestPair[0]
|
||||
responseHandler := regRequestPair[1]
|
||||
|
||||
if i >= regResponses {
|
||||
// Registry should not be responding
|
||||
responseHandler = delayedResponseHandler
|
||||
}
|
||||
|
||||
mockRegServer.AppendHandlers(ghttp.CombineHandlers(
|
||||
verifyHandler,
|
||||
responseHandler,
|
||||
))
|
||||
|
||||
if i >= regResponses {
|
||||
// No need to add further handlers since the last one is blocking
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
newImage := types.ImageInspect{ID: "newer_id"}
|
||||
|
||||
// Docker should respond normally
|
||||
mockServer.AppendHandlers(
|
||||
mocks.PullImageHandlerOK(),
|
||||
mocks.GetImageHandlerOK(cnt.ImageName(), &newImage),
|
||||
)
|
||||
|
||||
stale, latest, err := client.IsContainerStale(*cnt)
|
||||
|
||||
Expect(stale).To(Equal(latest == wt.ImageID(newImage.ID)))
|
||||
|
||||
return stale, err
|
||||
|
||||
}
|
||||
|
||||
When("head request times out", func() {
|
||||
It("should gracefully fail and continue using pull", func() {
|
||||
mockContainer := MockContainer(WithImageName(mockRegServer.Addr() + "/prefix/imagename:latest"))
|
||||
|
||||
regClient := registry.NewClientWithHTTPClient(mockRegServer.HTTPTestServer.Client())
|
||||
regClient.Timeout = time.Second * 2
|
||||
client := dockerClient{
|
||||
api: docker,
|
||||
ClientOptions: ClientOptions{PullImages: true},
|
||||
reg: regClient,
|
||||
}
|
||||
|
||||
stale, err := testRegPullFallback(&client, mockContainer, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(stale).To(BeTrue())
|
||||
|
||||
})
|
||||
})
|
||||
When("client request times out", func() {
|
||||
It("should fail with a useful message", func() {
|
||||
mockContainer := MockContainer(WithImageName(mockRegServer.Addr() + "/prefix/imagename:latest"))
|
||||
|
||||
regClient := registry.NewClientWithHTTPClient(mockRegServer.HTTPTestServer.Client())
|
||||
client := dockerClient{
|
||||
api: docker,
|
||||
ClientOptions: ClientOptions{
|
||||
Timeout: time.Second * 2,
|
||||
PullImages: true,
|
||||
},
|
||||
reg: regClient,
|
||||
}
|
||||
|
||||
_, err := testRegPullFallback(&client, mockContainer, 0)
|
||||
Expect(err).To(MatchError(context.DeadlineExceeded))
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
When("listing containers", func() {
|
||||
When("no filter is provided", func() {
|
||||
It("should return all available containers", func() {
|
||||
|
|
@ -199,7 +294,7 @@ var _ = Describe("the client", func() {
|
|||
logrus.SetOutput(logbuf)
|
||||
|
||||
user := ""
|
||||
containerID := t.ContainerID("ex-cont-id")
|
||||
containerID := wt.ContainerID("ex-cont-id")
|
||||
execID := "ex-exec-id"
|
||||
cmd := "exec-cmd"
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ func getContainerHandler(containerId string, responseHandler http.HandlerFunc) h
|
|||
)
|
||||
}
|
||||
|
||||
// GetContainerHandler mocks the GET containers/{id}/json endpoint
|
||||
// GetContainerHandler mocks the GET containers/{containerID}/json endpoint.
|
||||
//
|
||||
// If containerInfo is nil, it returns an 200 OK http result with the containerInfo as the body.
|
||||
// Otherwise, it returns the appropriate 404 NotFound result for the containerID.
|
||||
func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON) http.HandlerFunc {
|
||||
responseHandler := containerNotFoundResponse(containerID)
|
||||
if containerInfo != nil {
|
||||
|
|
@ -101,9 +104,30 @@ func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON)
|
|||
return getContainerHandler(containerID, responseHandler)
|
||||
}
|
||||
|
||||
// GetImageHandler mocks the GET images/{id}/json endpoint
|
||||
func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
|
||||
return getImageHandler(imageInfo.ID, ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
|
||||
// GetContainerHandlerOK mocks the GET containers/{containerInfo.ID}/json endpoint, returning the containerInfo
|
||||
func GetContainerHandlerOK(containerInfo *types.ContainerJSON) http.HandlerFunc {
|
||||
O.ExpectWithOffset(1, containerInfo).ToNot(O.BeNil())
|
||||
return GetContainerHandler(containerInfo.ID, containerInfo)
|
||||
}
|
||||
|
||||
// GetImageHandlerOK mocks the GET images/{imageName}/json endpoint, returning a 200 OK response with the imageInfo
|
||||
func GetImageHandlerOK(imageName string, imageInfo *types.ImageInspect) http.HandlerFunc {
|
||||
return getImageHandler(imageName, ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
|
||||
}
|
||||
|
||||
// GetImageHandlerNotFound mocks the GET images/{imageName}/json endpoint, returning a 404 NotFound response
|
||||
// with the appropriate error message for imageName
|
||||
func GetImageHandlerNotFound(imageName string) http.HandlerFunc {
|
||||
body := errorMessage{"no such image: " + imageName}
|
||||
return getImageHandler(imageName, ghttp.RespondWithJSONEncoded(http.StatusNotFound, body))
|
||||
}
|
||||
|
||||
// PullImageHandlerOK mocks the POST images/create endpoint, returning a 204 NoContent response
|
||||
func PullImageHandlerOK() http.HandlerFunc {
|
||||
return ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", O.HaveSuffix("images/create")),
|
||||
ghttp.RespondWith(http.StatusNoContent, nil),
|
||||
)
|
||||
}
|
||||
|
||||
// ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
|
||||
|
|
@ -179,8 +203,10 @@ func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerF
|
|||
)
|
||||
}
|
||||
|
||||
type errorMessage struct{ message string }
|
||||
|
||||
func containerNotFoundResponse(containerID string) http.HandlerFunc {
|
||||
return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + containerID})
|
||||
return ghttp.RespondWithJSONEncoded(http.StatusNotFound, errorMessage{"No such container: " + containerID})
|
||||
}
|
||||
|
||||
var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)
|
||||
|
|
|
|||
112
pkg/registry/auth.go
Normal file
112
pkg/registry/auth.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChallengeHeader is the HTTP Header containing challenge instructions
|
||||
const ChallengeHeader = "WWW-Authenticate"
|
||||
|
||||
// GetToken fetches a token for the registry hosting the provided image
|
||||
func (rc *Client) GetToken(ctx context.Context, container types.Container, registryAuth string) (string, error) {
|
||||
var err error
|
||||
var URL url.URL
|
||||
|
||||
if URL, err = auth.GetChallengeURL(container.ImageName()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
|
||||
|
||||
var req *http.Request
|
||||
if req, err = rc.GetChallengeRequest(ctx, URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var res *http.Response
|
||||
if res, err = rc.httpClient.Do(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
v := res.Header.Get(ChallengeHeader)
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"status": res.Status,
|
||||
"header": v,
|
||||
}).Debug("Got response to challenge request")
|
||||
|
||||
challenge := strings.ToLower(v)
|
||||
if strings.HasPrefix(challenge, "basic") {
|
||||
if registryAuth == "" {
|
||||
return "", fmt.Errorf("no credentials available")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Basic %s", registryAuth), nil
|
||||
}
|
||||
if strings.HasPrefix(challenge, "bearer") {
|
||||
return rc.GetBearerHeader(ctx, challenge, container.ImageName(), registryAuth)
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported challenge type from registry")
|
||||
}
|
||||
|
||||
// GetChallengeRequest creates a request for getting challenge instructions
|
||||
func (rc *Client) GetChallengeRequest(ctx context.Context, URL url.URL) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", "Watchtower (Docker)")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions
|
||||
func (rc *Client) GetBearerHeader(ctx context.Context, challenge string, img string, registryAuth string) (string, error) {
|
||||
if strings.Contains(img, ":") {
|
||||
img = strings.Split(img, ":")[0]
|
||||
}
|
||||
authURL, err := auth.GetAuthURL(challenge, img)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var r *http.Request
|
||||
if r, err = http.NewRequestWithContext(ctx, "GET", authURL.String(), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if registryAuth != "" {
|
||||
logrus.Debug("Credentials found.")
|
||||
logrus.Tracef("Credentials: %v", registryAuth)
|
||||
r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
|
||||
} else {
|
||||
logrus.Debug("No credentials found.")
|
||||
}
|
||||
|
||||
var authResponse *http.Response
|
||||
if authResponse, err = rc.httpClient.Do(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(authResponse.Body)
|
||||
tokenResponse := &types.TokenResponse{}
|
||||
|
||||
err = json.Unmarshal(body, tokenResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
|
||||
}
|
||||
|
|
@ -1,118 +1,15 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/registry/helpers"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChallengeHeader is the HTTP Header containing challenge instructions
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
logrus.WithField("URL", URL.String()).Debug("Building challenge URL")
|
||||
|
||||
var req *http.Request
|
||||
if req, err = GetChallengeRequest(URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
var res *http.Response
|
||||
if res, err = client.Do(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
v := res.Header.Get(ChallengeHeader)
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"status": res.Status,
|
||||
"header": v,
|
||||
}).Debug("Got response to challenge request")
|
||||
|
||||
challenge := strings.ToLower(v)
|
||||
if strings.HasPrefix(challenge, "basic") {
|
||||
if registryAuth == "" {
|
||||
return "", fmt.Errorf("no credentials available")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Basic %s", registryAuth), nil
|
||||
}
|
||||
if strings.HasPrefix(challenge, "bearer") {
|
||||
return GetBearerHeader(challenge, container.ImageName(), registryAuth)
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported challenge type from registry")
|
||||
}
|
||||
|
||||
// GetChallengeRequest creates a request for getting challenge instructions
|
||||
func GetChallengeRequest(URL url.URL) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", "Watchtower (Docker)")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
client := http.Client{}
|
||||
if strings.Contains(img, ":") {
|
||||
img = strings.Split(img, ":")[0]
|
||||
}
|
||||
authURL, err := GetAuthURL(challenge, img)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var r *http.Request
|
||||
if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if registryAuth != "" {
|
||||
logrus.Debug("Credentials found.")
|
||||
logrus.Tracef("Credentials: %v", registryAuth)
|
||||
r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth))
|
||||
} else {
|
||||
logrus.Debug("No credentials found.")
|
||||
}
|
||||
|
||||
var authResponse *http.Response
|
||||
if authResponse, err = client.Do(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(authResponse.Body)
|
||||
tokenResponse := &types.TokenResponse{}
|
||||
|
||||
err = json.Unmarshal(body, tokenResponse)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil
|
||||
}
|
||||
|
||||
// GetAuthURL from the instructions in the challenge
|
||||
func GetAuthURL(challenge string, img string) (*url.URL, error) {
|
||||
loweredChallenge := strings.ToLower(challenge)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
|
|
@ -38,29 +36,7 @@ var GHCRCredentials = &wtTypes.RegistryCredentials{
|
|||
}
|
||||
|
||||
var _ = Describe("the auth module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("getting an auth url", func() {
|
||||
It("should parse the token from the response",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
token, err := auth.GetToken(mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(token).NotTo(Equal(""))
|
||||
}),
|
||||
)
|
||||
|
||||
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"`
|
||||
expected := &url.URL{
|
||||
|
|
|
|||
38
pkg/registry/client.go
Normal file
38
pkg/registry/client.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClientWithHTTPClient returns a custom registry client useful for testing
|
||||
func NewClientWithHTTPClient(httpClient *http.Client) *Client {
|
||||
timeout := 30 * time.Second
|
||||
return &Client{
|
||||
httpClient,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient returns a registry client with the default values
|
||||
func NewClient() *Client {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
return NewClientWithHTTPClient(&http.Client{Transport: tr})
|
||||
}
|
||||
|
|
@ -1,35 +1,37 @@
|
|||
package digest
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/containrrr/watchtower/internal/meta"
|
||||
"github.com/containrrr/watchtower/pkg/registry/auth"
|
||||
"github.com/containrrr/watchtower/pkg/registry/manifest"
|
||||
"github.com/containrrr/watchtower/pkg/types"
|
||||
"github.com/sirupsen/logrus"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContentDigestHeader is the key for the key-value pair containing the digest header
|
||||
const ContentDigestHeader = "Docker-Content-Digest"
|
||||
|
||||
// CompareDigest ...
|
||||
func CompareDigest(container types.Container, registryAuth string) (bool, error) {
|
||||
// CompareDigest retrieves the latest digest for the container image from the registry
|
||||
// and returns whether it matches any of the containers current image's digest
|
||||
func (rc *Client) CompareDigest(ctx context.Context, container types.Container, registryAuth string) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, rc.Timeout)
|
||||
defer cancel()
|
||||
|
||||
if !container.HasImageInfo() {
|
||||
return false, errors.New("container image info missing")
|
||||
}
|
||||
|
||||
|
||||
var digest string
|
||||
|
||||
registryAuth = TransformAuth(registryAuth)
|
||||
token, err := auth.GetToken(container, registryAuth)
|
||||
token, err := rc.GetToken(ctx, container, registryAuth)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -39,7 +41,7 @@ func CompareDigest(container types.Container, registryAuth string) (bool, error)
|
|||
return false, err
|
||||
}
|
||||
|
||||
if digest, err = GetDigest(digestURL, token); err != nil {
|
||||
if digest, err = rc.GetDigest(ctx, digestURL, token); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
|
@ -74,23 +76,9 @@ func TransformAuth(registryAuth string) string {
|
|||
}
|
||||
|
||||
// GetDigest from registry using a HEAD request to prevent rate limiting
|
||||
func GetDigest(url string, token string) (string, error) {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
func (rc *Client) GetDigest(ctx context.Context, url string, token string) (string, error) {
|
||||
|
||||
req, _ := http.NewRequest("HEAD", url, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "HEAD", url, nil)
|
||||
req.Header.Set("User-Agent", meta.UserAgent)
|
||||
|
||||
if token != "" {
|
||||
|
|
@ -106,7 +94,7 @@ func GetDigest(url string, token string) (string, error) {
|
|||
|
||||
logrus.WithField("url", url).Debug("Doing a HEAD request to fetch a digest")
|
||||
|
||||
res, err := client.Do(req)
|
||||
res, err := rc.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -1,25 +1,20 @@
|
|||
package digest_test
|
||||
package registry_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/containrrr/watchtower/internal/actions/mocks"
|
||||
"github.com/containrrr/watchtower/pkg/registry/digest"
|
||||
"github.com/containrrr/watchtower/pkg/registry"
|
||||
wtTypes "github.com/containrrr/watchtower/pkg/types"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/ghttp"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func TestDigest(t *testing.T) {
|
||||
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(GinkgoT(), "Digest Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
DockerHubCredentials = &wtTypes.RegistryCredentials{
|
||||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
|
||||
|
|
@ -29,6 +24,8 @@ var (
|
|||
Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
|
||||
Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
|
||||
}
|
||||
ctx = context.Background()
|
||||
rc = registry.NewClientWithHTTPClient(http.DefaultClient)
|
||||
)
|
||||
|
||||
func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
|
||||
|
|
@ -65,7 +62,7 @@ var _ = Describe("Digests", func() {
|
|||
It("should return true if digests match",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
matches, err := digest.CompareDigest(mockContainer, creds)
|
||||
matches, err := rc.CompareDigest(ctx, mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(matches).To(Equal(true))
|
||||
}),
|
||||
|
|
@ -78,7 +75,7 @@ var _ = Describe("Digests", func() {
|
|||
|
||||
})
|
||||
It("should return an error when container contains no image info", func() {
|
||||
matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`)
|
||||
matches, err := rc.CompareDigest(ctx, mockContainerNoImage, `user:pass`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(matches).To(Equal(false))
|
||||
})
|
||||
|
|
@ -110,16 +107,42 @@ var _ = Describe("Digests", func() {
|
|||
"User-Agent": []string{"Watchtower/v0.0.0-unknown"},
|
||||
}),
|
||||
ghttp.RespondWith(http.StatusOK, "", http.Header{
|
||||
digest.ContentDigestHeader: []string{
|
||||
registry.ContentDigestHeader: []string{
|
||||
mockDigest,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
dig, err := digest.GetDigest(server.URL(), "token")
|
||||
dig, err := rc.GetDigest(ctx, server.URL(), "token")
|
||||
Expect(server.ReceivedRequests()).Should(HaveLen(1))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(dig).To(Equal(mockDigest))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("the auth module", func() {
|
||||
mockId := "mock-id"
|
||||
mockName := "mock-container"
|
||||
mockImage := "ghcr.io/k6io/operator:latest"
|
||||
mockCreated := time.Now()
|
||||
mockDigest := "ghcr.io/k6io/operator@sha256:d68e1e532088964195ad3a0a71526bc2f11a78de0def85629beb75e2265f0547"
|
||||
|
||||
mockContainer := mocks.CreateMockContainerWithDigest(
|
||||
mockId,
|
||||
mockName,
|
||||
mockImage,
|
||||
mockCreated,
|
||||
mockDigest)
|
||||
|
||||
When("getting an auth url", func() {
|
||||
It("should parse the token from the response",
|
||||
SkipIfCredentialsEmpty(GHCRCredentials, func() {
|
||||
creds := fmt.Sprintf("%s:%s", GHCRCredentials.Username, GHCRCredentials.Password)
|
||||
token, err := rc.GetToken(ctx, mockContainer, creds)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(token).NotTo(Equal(""))
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue