You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
watchtower/pkg/container/client_test.go

375 lines
13 KiB
Go

package container
import (
"time"
"github.com/containrrr/watchtower/internal/testing/delayhttp"
"github.com/containrrr/watchtower/pkg/container/mocks"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/containrrr/watchtower/pkg/registry"
t "github.com/containrrr/watchtower/pkg/types"
wt "github.com/containrrr/watchtower/pkg/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/backend"
cli "github.com/docker/docker/client"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/ghttp"
"github.com/sirupsen/logrus"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
gt "github.com/onsi/gomega/types"
"context"
"net/http"
)
var _ = Describe("the client", func() {
var docker *cli.Client
var mockServer *ghttp.Server
mockRegServer := ghttp.NewTLSServer()
BeforeEach(func() {
mockServer = ghttp.NewServer()
docker, _ = cli.NewClientWithOpts(
cli.WithHost(mockServer.URL()),
cli.WithHTTPClient(mockServer.HTTPTestServer.Client()))
})
AfterEach(func() {
mockServer.Close()
mockRegServer.Reset()
})
Describe("WarnOnHeadPullFailed", func() {
containerUnknown := *MockContainer(WithImageName("unknown.repo/prefix/imagename:latest"))
containerKnown := *MockContainer(WithImageName("docker.io/prefix/imagename:latest"))
When(`warn on head failure is set to "always"`, func() {
c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAlways}}
It("should always return true", func() {
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
})
})
When(`warn on head failure is set to "auto"`, func() {
c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnAuto}}
It("should return false for unknown repos", func() {
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
})
It("should return true for known repos", func() {
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
})
})
When(`warn on head failure is set to "never"`, func() {
c := dockerClient{ClientOptions: ClientOptions{WarnOnHeadFailed: WarnNever}}
It("should never return true", func() {
Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
})
})
})
When("pulling the latest image", func() {
When("the image consist of a pinned hash", func() {
It("should gracefully fail with a useful message", func() {
c := dockerClient{}
pinnedContainer := *MockContainer(WithImageName("sha256:fa5269854a5e615e51a72b17ad3fd1e01268f278a6684c8ed3c5f0cdce3f230b"))
c.PullImage(context.Background(), pinnedContainer)
})
})
})
When("removing a running container", func() {
When("the container still exist after stopping", func() {
It("should attempt to remove the container", func() {
container := *MockContainer(WithContainerState(types.ContainerState{Running: true}))
containerStopped := *MockContainer(WithContainerState(types.ContainerState{Running: false}))
cid := container.ContainerInfo().ID
mockServer.AppendHandlers(
mocks.KillContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, containerStopped.ContainerInfo()),
mocks.RemoveContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, nil),
)
Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
})
})
When("the container does not exist after stopping", func() {
It("should not cause an error", func() {
container := *MockContainer(WithContainerState(types.ContainerState{Running: true}))
cid := container.ContainerInfo().ID
mockServer.AppendHandlers(
mocks.KillContainerHandler(cid, mocks.Found),
mocks.GetContainerHandler(cid, nil),
mocks.RemoveContainerHandler(cid, mocks.Missing),
)
Expect(dockerClient{api: docker}.StopContainer(container, time.Minute)).To(Succeed())
})
})
})
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() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).To(HaveLen(2))
})
})
When("a filter matching nothing", func() {
It("should return an empty array", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
containers, err := client.ListContainers(filter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).To(BeEmpty())
})
})
When("a watchtower filter is provided", func() {
It("should return only the watchtower container", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).To(ConsistOf(withContainerImageName(Equal("containrrr/watchtower:latest"))))
})
})
When(`include stopped is enabled`, func() {
It("should return both stopped and running containers", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running", "exited", "created"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("stopped", "watchtower", "running")...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeStopped: true},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).To(ContainElement(havingRunningState(false)))
})
})
When(`include restarting is enabled`, func() {
It("should return both restarting and running containers", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running", "restarting"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running", "restarting")...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: true},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).To(ContainElement(havingRestartingState(true)))
})
})
When(`include restarting is disabled`, func() {
It("should not return restarting containers", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: false},
}
containers, err := client.ListContainers(filters.NoFilter)
Expect(err).NotTo(HaveOccurred())
Expect(containers).NotTo(ContainElement(havingRestartingState(true)))
})
})
})
Describe(`ExecuteCommand`, func() {
When(`logging`, func() {
It("should include container id field", func() {
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
// Capture logrus output in buffer
logbuf := gbytes.NewBuffer()
origOut := logrus.StandardLogger().Out
defer logrus.SetOutput(origOut)
logrus.SetOutput(logbuf)
user := ""
containerID := t.ContainerID("ex-cont-id")
execID := "ex-exec-id"
cmd := "exec-cmd"
mockServer.AppendHandlers(
// API.ContainerExecCreate
ghttp.CombineHandlers(
ghttp.VerifyRequest("POST", HaveSuffix("containers/%v/exec", containerID)),
ghttp.VerifyJSONRepresenting(types.ExecConfig{
User: user,
Detach: false,
Tty: true,
Cmd: []string{
"sh",
"-c",
cmd,
},
}),
ghttp.RespondWithJSONEncoded(http.StatusOK, types.IDResponse{ID: execID}),
),
// API.ContainerExecStart
ghttp.CombineHandlers(
ghttp.VerifyRequest("POST", HaveSuffix("exec/%v/start", execID)),
ghttp.VerifyJSONRepresenting(types.ExecStartCheck{
Detach: false,
Tty: true,
}),
ghttp.RespondWith(http.StatusOK, nil),
),
// API.ContainerExecInspect
ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", HaveSuffix("exec/ex-exec-id/json")),
ghttp.RespondWithJSONEncoded(http.StatusOK, backend.ExecInspect{
ID: execID,
Running: false,
ExitCode: nil,
ProcessConfig: &backend.ExecProcessConfig{
Entrypoint: "sh",
Arguments: []string{"-c", cmd},
User: user,
},
ContainerID: string(containerID),
}),
),
)
_, err := client.ExecuteCommand(containerID, cmd, 1)
Expect(err).NotTo(HaveOccurred())
// Note: Since Execute requires opening up a raw TCP stream to the daemon for the output, this will fail
// when using the mock API server. Regardless of the outcome, the log should include the container ID
Eventually(logbuf).Should(gbytes.Say(`containerID="?ex-cont-id"?`))
})
})
})
})
// Gomega matcher helpers
func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
return WithTransform(containerImageName, matcher)
}
func containerImageName(container Container) string {
return container.ImageName()
}
func havingRestartingState(expected bool) gt.GomegaMatcher {
return WithTransform(func(container Container) bool {
return container.containerInfo.State.Restarting
}, Equal(expected))
}
func havingRunningState(expected bool) gt.GomegaMatcher {
return WithTransform(func(container Container) bool {
return container.containerInfo.State.Running
}, Equal(expected))
}