Skip to content

Commit dca45f5

Browse files
schizo99pikselandreas-ahman
authored
feat: support container network mode (#1429)
Co-authored-by: nils måsén <[email protected]> Co-authored-by: Andreas Åhman <[email protected]>
1 parent bba9b2b commit dca45f5

15 files changed

+1379
-47
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
services:
2+
producer:
3+
image: qmcgaw/gluetun:v3.35.0
4+
cap_add:
5+
- NET_ADMIN
6+
environment:
7+
- VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
8+
- OPENVPN_USER=${OPENVPN_USER}
9+
- OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
10+
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
11+
consumer:
12+
depends_on:
13+
- producer
14+
image: nginx:1.25.1
15+
network_mode: "service:producer"
16+
labels:
17+
- "com.centurylinklabs.watchtower.depends-on=/wt-contnet-producer-1"

docs/linked-containers.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ Watchtower will detect if there are links between any of the running containers
22

33
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.
44

5-
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.
5+
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.
6+
7+
When you have a depending container that is using `network_mode: service:container` then watchtower will treat that container as an implicit link.

pkg/container/client.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ func (client dockerClient) GetContainer(containerID t.ContainerID) (t.Container,
157157
return &Container{}, err
158158
}
159159

160+
netType, netContainerId, found := strings.Cut(string(containerInfo.HostConfig.NetworkMode), ":")
161+
if found && netType == "container" {
162+
parentContainer, err := client.api.ContainerInspect(bg, netContainerId)
163+
if err != nil {
164+
log.WithFields(map[string]interface{}{
165+
"container": containerInfo.Name,
166+
"error": err,
167+
"network-container": netContainerId,
168+
}).Warnf("Unable to resolve network container: %v", err)
169+
170+
} else {
171+
// Replace the container ID with a container name to allow it to reference the re-created network container
172+
containerInfo.HostConfig.NetworkMode = container.NetworkMode(fmt.Sprintf("container:%s", parentContainer.Name))
173+
}
174+
}
175+
160176
imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
161177
if err != nil {
162178
log.Warnf("Failed to retrieve container image info: %v", err)

pkg/container/client_test.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ var _ = Describe("the client", func() {
141141
When("no filter is provided", func() {
142142
It("should return all available containers", func() {
143143
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
144-
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
144+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
145145
client := dockerClient{
146146
api: docker,
147147
ClientOptions: ClientOptions{PullImages: false},
@@ -154,7 +154,7 @@ var _ = Describe("the client", func() {
154154
When("a filter matching nothing", func() {
155155
It("should return an empty array", func() {
156156
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
157-
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
157+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
158158
filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
159159
client := dockerClient{
160160
api: docker,
@@ -168,7 +168,7 @@ var _ = Describe("the client", func() {
168168
When("a watchtower filter is provided", func() {
169169
It("should return only the watchtower container", func() {
170170
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
171-
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
171+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
172172
client := dockerClient{
173173
api: docker,
174174
ClientOptions: ClientOptions{PullImages: false},
@@ -181,7 +181,7 @@ var _ = Describe("the client", func() {
181181
When(`include stopped is enabled`, func() {
182182
It("should return both stopped and running containers", func() {
183183
mockServer.AppendHandlers(mocks.ListContainersHandler("running", "exited", "created"))
184-
mockServer.AppendHandlers(mocks.GetContainerHandlers("stopped", "watchtower", "running")...)
184+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Stopped, &mocks.Watchtower, &mocks.Running)...)
185185
client := dockerClient{
186186
api: docker,
187187
ClientOptions: ClientOptions{PullImages: false, IncludeStopped: true},
@@ -194,7 +194,7 @@ var _ = Describe("the client", func() {
194194
When(`include restarting is enabled`, func() {
195195
It("should return both restarting and running containers", func() {
196196
mockServer.AppendHandlers(mocks.ListContainersHandler("running", "restarting"))
197-
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running", "restarting")...)
197+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running, &mocks.Restarting)...)
198198
client := dockerClient{
199199
api: docker,
200200
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: true},
@@ -207,7 +207,7 @@ var _ = Describe("the client", func() {
207207
When(`include restarting is disabled`, func() {
208208
It("should not return restarting containers", func() {
209209
mockServer.AppendHandlers(mocks.ListContainersHandler("running"))
210-
mockServer.AppendHandlers(mocks.GetContainerHandlers("watchtower", "running")...)
210+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&mocks.Watchtower, &mocks.Running)...)
211211
client := dockerClient{
212212
api: docker,
213213
ClientOptions: ClientOptions{PullImages: false, IncludeRestarting: false},
@@ -217,6 +217,36 @@ var _ = Describe("the client", func() {
217217
Expect(containers).NotTo(ContainElement(havingRestartingState(true)))
218218
})
219219
})
220+
When(`a container uses container network mode`, func() {
221+
When(`the network container can be resolved`, func() {
222+
It("should return the container name instead of the ID", func() {
223+
consumerContainerRef := mocks.NetConsumerOK
224+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
225+
client := dockerClient{
226+
api: docker,
227+
ClientOptions: ClientOptions{PullImages: false},
228+
}
229+
container, err := client.GetContainer(consumerContainerRef.ContainerID())
230+
Expect(err).NotTo(HaveOccurred())
231+
networkMode := container.ContainerInfo().HostConfig.NetworkMode
232+
Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierContainerName))
233+
})
234+
})
235+
When(`the network container cannot be resolved`, func() {
236+
It("should still return the container ID", func() {
237+
consumerContainerRef := mocks.NetConsumerInvalidSupplier
238+
mockServer.AppendHandlers(mocks.GetContainerHandlers(&consumerContainerRef)...)
239+
client := dockerClient{
240+
api: docker,
241+
ClientOptions: ClientOptions{PullImages: false},
242+
}
243+
container, err := client.GetContainer(consumerContainerRef.ContainerID())
244+
Expect(err).NotTo(HaveOccurred())
245+
networkMode := container.ContainerInfo().HostConfig.NetworkMode
246+
Expect(networkMode.ConnectedContainer()).To(Equal(mocks.NetSupplierNotFoundID))
247+
})
248+
})
249+
})
220250
})
221251
Describe(`ExecuteCommand`, func() {
222252
When(`logging`, func() {

pkg/container/container.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ func (c Container) Links() []string {
196196
name := strings.Split(link, ":")[0]
197197
links = append(links, name)
198198
}
199+
200+
// If the container uses another container for networking, it can be considered an implicit link
201+
// since the container would stop working if the network supplier were to be recreated
202+
networkMode := c.containerInfo.HostConfig.NetworkMode
203+
if networkMode.IsContainer() {
204+
links = append(links, networkMode.ConnectedContainer())
205+
}
199206
}
200207

201208
return links

pkg/container/mocks/ApiServer.go

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package mocks
33
import (
44
"encoding/json"
55
"fmt"
6-
"io/ioutil"
6+
"github.com/onsi/ginkgo"
77
"net/http"
88
"net/url"
9+
"os"
910
"path/filepath"
1011
"strings"
1112

13+
t "github.com/containrrr/watchtower/pkg/types"
14+
1215
"github.com/docker/docker/api/types"
1316
"github.com/docker/docker/api/types/filters"
1417
O "github.com/onsi/gomega"
@@ -17,10 +20,9 @@ import (
1720

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

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

49-
// Also append the image request since that will be called for every container
50-
if file == "running" {
51-
// The "running" container is the only one using image02
52-
handlers = append(handlers, getImageFileHandler(1))
53-
} else {
54-
handlers = append(handlers, getImageFileHandler(0))
51+
// Also append any containers that the container references, if any
52+
for _, ref := range containerRef.references {
53+
handlers = append(handlers, getContainerFileHandler(ref))
5554
}
55+
56+
// Also append the image request since that will be called for every container
57+
handlers = append(handlers, getImageHandler(containerRef.image.id,
58+
RespondWithJSONFile(containerRef.image.getFileName(), http.StatusOK),
59+
))
5660
}
61+
5762
return handlers
5863
}
5964

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

68-
var containerFileIds = map[string]string{
69-
"stopped": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
70-
"watchtower": "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
71-
"running": "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
72-
"restarting": "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
73+
var defaultImage = imageRef{
74+
// watchtower
75+
id: t.ImageID("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa"),
76+
file: "default",
77+
}
78+
79+
var Watchtower = ContainerRef{
80+
name: "watchtower",
81+
id: "3d88e0e3543281c747d88b27e246578b65ae8964ba86c7cd7522cf84e0978134",
82+
image: &defaultImage,
83+
}
84+
var Stopped = ContainerRef{
85+
name: "stopped",
86+
id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65",
87+
image: &defaultImage,
88+
}
89+
var Running = ContainerRef{
90+
name: "running",
91+
id: "b978af0b858aa8855cce46b628817d4ed58e58f2c4f66c9b9c5449134ed4c008",
92+
image: &imageRef{
93+
// portainer
94+
id: t.ImageID("sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd"),
95+
file: "running",
96+
},
97+
}
98+
var Restarting = ContainerRef{
99+
name: "restarting",
100+
id: "ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b67",
101+
image: &defaultImage,
102+
}
103+
104+
var netSupplierOK = ContainerRef{
105+
id: "25e75393800b5c450a6841212a3b92ed28fa35414a586dec9f2c8a520d4910c2",
106+
name: "net_supplier",
107+
image: &imageRef{
108+
// gluetun
109+
id: t.ImageID("sha256:c22b543d33bfdcb9992cbef23961677133cdf09da71d782468ae2517138bad51"),
110+
file: "net_producer",
111+
},
112+
}
113+
var netSupplierNotFound = ContainerRef{
114+
id: NetSupplierNotFoundID,
115+
name: netSupplierOK.name,
116+
isMissing: true,
117+
}
118+
119+
// NetConsumerOK is used for testing `container` networking mode
120+
// returns a container that consumes an existing supplier container
121+
var NetConsumerOK = ContainerRef{
122+
id: "1f6b79d2aff23244382026c76f4995851322bed5f9c50631620162f6f9aafbd6",
123+
name: "net_consumer",
124+
image: &imageRef{
125+
id: t.ImageID("sha256:904b8cb13b932e23230836850610fa45dce9eb0650d5618c2b1487c2a4f577b8"), // nginx
126+
file: "net_consumer",
127+
},
128+
references: []*ContainerRef{&netSupplierOK},
73129
}
74130

75-
var imageIds = []string{
76-
"sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa",
77-
"sha256:19d07168491a3f9e2798a9bed96544e34d57ddc4757a4ac5bb199dea896c87fd",
131+
// NetConsumerInvalidSupplier is used for testing `container` networking mode
132+
// returns a container that references a supplying container that does not exist
133+
var NetConsumerInvalidSupplier = ContainerRef{
134+
id: NetConsumerOK.id,
135+
name: "net_consumer-missing_supplier",
136+
image: NetConsumerOK.image,
137+
references: []*ContainerRef{&netSupplierNotFound},
78138
}
79139

80-
func getContainerFileHandler(file string) http.HandlerFunc {
81-
id, ok := containerFileIds[file]
82-
failTestUnless(ok)
140+
const NetSupplierNotFoundID = "badc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc1dbadc"
141+
const NetSupplierContainerName = "/wt-contnet-producer-1"
142+
143+
func getContainerFileHandler(cr *ContainerRef) http.HandlerFunc {
144+
145+
if cr.isMissing {
146+
return containerNotFoundResponse(string(cr.id))
147+
}
148+
149+
containerFile, err := cr.getContainerFile()
150+
if err != nil {
151+
ginkgo.Fail(fmt.Sprintf("Failed to get container mock file: %v", err))
152+
}
153+
83154
return getContainerHandler(
84-
id,
85-
RespondWithJSONFile(fmt.Sprintf("./mocks/data/container_%v.json", file), http.StatusOK),
155+
string(cr.id),
156+
RespondWithJSONFile(containerFile, http.StatusOK),
86157
)
87158
}
88159

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

105176
// GetImageHandler mocks the GET images/{id}/json endpoint
106177
func GetImageHandler(imageInfo *types.ImageInspect) http.HandlerFunc {
107-
return getImageHandler(imageInfo.ID, ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
178+
return getImageHandler(t.ImageID(imageInfo.ID), ghttp.RespondWithJSONEncoded(http.StatusOK, imageInfo))
108179
}
109180

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

141-
func getImageHandler(imageId string, responseHandler http.HandlerFunc) http.HandlerFunc {
212+
func getImageHandler(imageId t.ImageID, responseHandler http.HandlerFunc) http.HandlerFunc {
142213
return ghttp.CombineHandlers(
143214
ghttp.VerifyRequest("GET", O.HaveSuffix("/images/%s/json", imageId)),
144215
responseHandler,
145216
)
146217
}
147218

148-
func getImageFileHandler(index int) http.HandlerFunc {
149-
return getImageHandler(imageIds[index],
150-
RespondWithJSONFile(fmt.Sprintf("./mocks/data/image%02d.json", index+1), http.StatusOK),
151-
)
152-
}
153-
154-
func failTestUnless(ok bool) {
155-
O.ExpectWithOffset(2, ok).To(O.BeTrue(), "test setup failed")
156-
}
157-
158219
// KillContainerHandler mocks the POST containers/{id}/kill endpoint
159220
func KillContainerHandler(containerID string, found FoundStatus) http.HandlerFunc {
160221
responseHandler := noContentStatusResponse
@@ -180,7 +241,7 @@ func RemoveContainerHandler(containerID string, found FoundStatus) http.HandlerF
180241
}
181242

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

186247
var noContentStatusResponse = ghttp.RespondWith(http.StatusNoContent, nil)

0 commit comments

Comments
 (0)