Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support container network mode #1429

Merged
merged 10 commits into from
Aug 8, 2023
17 changes: 17 additions & 0 deletions dockerfiles/container-networking/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
producer:
image: qmcgaw/gluetun:v3.35.0
cap_add:
- NET_ADMIN
environment:
- VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
- OPENVPN_USER=${OPENVPN_USER}
- OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
consumer:
depends_on:
- producer
image: nginx:1.25.1
network_mode: "service:producer"
labels:
- "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1"
4 changes: 3 additions & 1 deletion docs/linked-containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ Watchtower will detect if there are links between any of the running containers

For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work.

If you want to override existing links you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma.
If you want to override existing links, or if you are not using links, you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma.

When you have a depending container that is using `network_mode: service:container` then watchtower will treat that container as an implicit link.
16 changes: 16 additions & 0 deletions pkg/container/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container,
return &Container{}, err
}

netType, netContainerId, found := strings.Cut(string(containerInfo.HostConfig.NetworkMode), ":")
if found && netType == "container" {
parentContainer, err := client.api.ContainerInspect(bg, netContainerId)
if err != nil {
log.WithFields(map[string]interface{}{
"container": containerInfo.Name,
"error": err,
"network-container": netContainerId,
}).Warnf("Unable to resolve network container: %v", err)

} else {
// Replace the container ID with a container name to allow it to reference the re-created network container
containerInfo.HostConfig.NetworkMode = container.NetworkMode(fmt.Sprintf("container:%s", parentContainer.Name))
}
}

imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
if err != nil {
log.Warnf("Failed to retrieve container image info: %v", err)
Expand Down
42 changes: 36 additions & 6 deletions pkg/container/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ var _ = Describe("the client", 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")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
Expand All @@ -153,7 +153,7 @@ var _ = Describe("the client", func() {
When("a filter matching nothing", func() {
It("should return an empty array", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
client := dockerClient{
api: docker,
Expand All @@ -167,7 +167,7 @@ var _ = Describe("the client", func() {
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")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
Expand All @@ -180,7 +180,7 @@ var _ = Describe("the client", func() {
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")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeStopped: true},
Expand All @@ -193,7 +193,7 @@ var _ = Describe("the client", func() {
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")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: true},
Expand All @@ -206,7 +206,7 @@ var _ = Describe("the client", func() {
When(`include restarting is disabled`, func() {
It("should not return restarting containers", func() {
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: false},
Expand All @@ -216,6 +216,36 @@ var _ = Describe("the client", func() {
Expect(containers).NotTo(ContainElement(havingRestartingState(true)))
})
})
When(`a container uses container network mode`, func() {
When(`the network container can be resolved`, func() {
It("should return the container name instead of the ID", func() {
consumerContainerRef := mocks.NetConsumerOK
mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
container, err := client.GetContainer(consumerContainerRef.ContainerID())
Expect(err).NotTo(HaveOccurred())
networkMode := container.ContainerInfo().HostConfig.NetworkMode
Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierContainerName))
})
})
When(`the network container cannot be resolved`, func() {
It("should still return the container ID", func() {
consumerContainerRef := mocks.NetConsumerInvalidSupplier
mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
client := dockerClient{
api: docker,
ClientOptions: ClientOptions{PullImages: false},
}
container, err := client.GetContainer(consumerContainerRef.ContainerID())
Expect(err).NotTo(HaveOccurred())
networkMode := container.ContainerInfo().HostConfig.NetworkMode
Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierNotFoundID))
})
})
})
})
Describe(`ExecuteCommand`, func() {
When(`logging`, func() {
Expand Down
7 changes: 7 additions & 0 deletions pkg/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@
name := strings.Split(link, ":")[0]
links = append(links, name)
}

// If the container uses another container for networking, it can be considered an implicit link
// since the container would stop working if the network supplier were to be recreated
networkMode := c.containerInfo.HostConfig.NetworkMode
if networkMode.IsContainer() {
links = append(links, networkMode.ConnectedContainer())
}

Check warning on line 205 in pkg/container/container.go

View check run for this annotation

Codecov / codecov/patch

pkg/container/container.go#L204-L205

Added lines #L204 - L205 were not covered by tests
}

return links
Expand Down
141 changes: 101 additions & 40 deletions pkg/container/mocks/ApiServer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package mocks
import (
"encoding/json"
"fmt"
"io/ioutil"
"github.com/onsi/ginkgo"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"

t "github.com/containrrr/watchtower/pkg/types"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
O "github.com/onsi/gomega"
Expand All @@ -17,10 +20,9 @@ import (

func getMockJSONFile(relPath string) ([]byte, error) {
absPath, _ := filepath.Abs(relPath)
buf, err := ioutil.ReadFile(absPath)
buf, err := os.ReadFile(absPath)
if err != nil {
// logrus.WithError(err).WithField("file", absPath).Error(err)
return nil, err
return nil, fmt.Errorf("mock JSON file %q not found: %e", absPath, err)
}
return buf, nil
}
Expand All @@ -41,19 +43,22 @@ func respondWithJSONFile(relPath string, statusCode int, optionalHeader ...http.
}

// GetContainerHandlers returns the handlers serving lookups for the supplied container mock files
func GetContainerHandlers(containerFiles ...string) []http.HandlerFunc {
handlers := make([]http.HandlerFunc, 0, len(containerFiles)*2)
for _, file := range containerFiles {
handlers = append(handlers, getContainerFileHandler(file))
func GetContainerHandlers(containerRefs ...*ContainerRef) []http.HandlerFunc {
handlers := make([]http.HandlerFunc, 0, len(containerRefs)*3)
for _, containerRef := range containerRefs {
handlers = append(handlers, getContainerFileHandler(containerRef))

// Also append the image request since that will be called for every container
if file == "running" {
// The "running" container is the only one using image02
handlers = append(handlers, getImageFileHandler(1))
} else {
handlers = append(handlers, getImageFileHandler(0))
// Also append any containers that the container references, if any
for _, ref := range containerRef.references {
handlers = append(handlers, getContainerFileHandler(ref))
}

// Also append the image request since that will be called for every container
handlers = append(handlers, getImageHandler(containerRef.image.id,
RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK),
))
}

return handlers
}

Expand All @@ -65,24 +70,90 @@ func createFilterArgs(statuses []string) filters.Args {
return args
}

var containerFileIds = map[string]string{
"stopped": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
"watchtower": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
"running": "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
"restarting": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
var defaultImage = imageRef{
// watchtower
id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"),
file: "default",
}

var Watchtower = ContainerRef{
name: "watchtower",
id: "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
image: &defaultImage,
}
var Stopped = ContainerRef{
name: "stopped",
id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
image: &defaultImage,
}
var Running = ContainerRef{
name: "running",
id: "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
image: &imageRef{
// portainer
id: t.ImageID("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd"),
file: "running",
},
}
var Restarting = ContainerRef{
name: "restarting",
id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
image: &defaultImage,
}

var netSupplierOK = ContainerRef{
id: "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
name: "net_supplier",
image: &imageRef{
// gluetun
id: t.ImageID("sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51"),
file: "net_producer",
},
}
var netSupplierNotFound = ContainerRef{
id: NetSupplierNotFoundID,
name: netSupplierOK.name,
isMissing: true,
}

// NetConsumerOK is used for testing `container` networking mode
// returns a container that consumes an existing supplier container
var NetConsumerOK = ContainerRef{
id: "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
name: "net_consumer",
image: &imageRef{
id: t.ImageID("sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8"), // nginx
file: "net_consumer",
},
references: []*ContainerRef{&netSupplierOK},
}

var imageIds = []string{
"sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
"sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
// NetConsumerInvalidSupplier is used for testing `container` networking mode
// returns a container that references a supplying container that does not exist
var NetConsumerInvalidSupplier = ContainerRef{
id: NetConsumerOK.id,
name: "net_consumer-missing_supplier",
image: NetConsumerOK.image,
references: []*ContainerRef{&netSupplierNotFound},
}

func getContainerFileHandler(file string) http.HandlerFunc {
id, ok := containerFileIds[file]
failTestUnless(ok)
const NetSupplierNotFoundID = "badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc"
const NetSupplierContainerName = "/wt-contnet-producer-1"

func getContainerFileHandler(cr *ContainerRef) http.HandlerFunc {

if cr.isMissing {
return containerNotFoundResponse(string(cr.id))
}

containerFile, err := cr.getContainerFile()
if err != nil {
ginkgo.Fail(fmt.Sprintf("Failed to get container mock file: %v", err))
}

return getContainerHandler(
id,
RespondWithJSONFile(fmt.Sprintf("./mocks/data/container_%v.json", file), http.StatusOK),
string(cr.id),
RespondWithJSONFile(containerFile, http.StatusOK),
)
}

Expand All @@ -104,7 +175,7 @@ func GetContainerHandler(containerID string, containerInfo *types.ContainerJSON)

// GetImageHandler mocks the GET images/{id}/json endpoint
func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
return getImageHandler(imageInfo.ID, ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
return getImageHandler(t.ImageID(imageInfo.ID), ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
}

// ListContainersHandler mocks the GET containers/json endpoint, filtering the returned containers based on statuses
Expand Down Expand Up @@ -138,23 +209,13 @@ func respondWithFilteredContainers(filters filters.Args) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusOK, filteredContainers)
}

func getImageHandler(imageId string, responseHandler http.HandlerFunc) http.HandlerFunc {
func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc {
return ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
responseHandler,
)
}

func getImageFileHandler(index int) http.HandlerFunc {
return getImageHandler(imageIds[index],
RespondWithJSONFile(fmt.Sprintf("./mocks/data/image%02d.json", index+1), http.StatusOK),
)
}

func failTestUnless(ok bool) {
O.ExpectWithOffset(2, ok).To(O.BeTrue(), "test setup failed")
}

// KillContainerHandler mocks the POST containers/{id}/kill endpoint
func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
responseHandler := noContentStatusResponse
Expand All @@ -180,7 +241,7 @@ func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerF
}

func containerNotFoundResponse(containerID string) http.HandlerFunc {
return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + containerID})
return ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{message: "No such container: " + string(containerID)})
}

var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)
Expand Down
Loading
Loading