Browse code

Synchronize /etc/hosts updates at file level

Introduced a path level lock to synchronize updates
to /etc/hosts writes. A path level cache is maintained
to only synchronize only at the file level.

Signed-off-by: Jana Radhakrishnan <mrjana@docker.com>

Jana Radhakrishnan authored on 2015/10/21 14:50:23
Showing 3 changed files
... ...
@@ -7,6 +7,7 @@ import (
7 7
 	"io/ioutil"
8 8
 	"os"
9 9
 	"regexp"
10
+	"sync"
10 11
 )
11 12
 
12 13
 // Record Structure for a single host record
... ...
@@ -21,14 +22,47 @@ func (r Record) WriteTo(w io.Writer) (int64, error) {
21 21
 	return int64(n), err
22 22
 }
23 23
 
24
-// Default hosts config records slice
25
-var defaultContent = []Record{
26
-	{Hosts: "localhost", IP: "127.0.0.1"},
27
-	{Hosts: "localhost ip6-localhost ip6-loopback", IP: "::1"},
28
-	{Hosts: "ip6-localnet", IP: "fe00::0"},
29
-	{Hosts: "ip6-mcastprefix", IP: "ff00::0"},
30
-	{Hosts: "ip6-allnodes", IP: "ff02::1"},
31
-	{Hosts: "ip6-allrouters", IP: "ff02::2"},
24
+var (
25
+	// Default hosts config records slice
26
+	defaultContent = []Record{
27
+		{Hosts: "localhost", IP: "127.0.0.1"},
28
+		{Hosts: "localhost ip6-localhost ip6-loopback", IP: "::1"},
29
+		{Hosts: "ip6-localnet", IP: "fe00::0"},
30
+		{Hosts: "ip6-mcastprefix", IP: "ff00::0"},
31
+		{Hosts: "ip6-allnodes", IP: "ff02::1"},
32
+		{Hosts: "ip6-allrouters", IP: "ff02::2"},
33
+	}
34
+
35
+	// A cache of path level locks for synchronizing /etc/hosts
36
+	// updates on a file level
37
+	pathMap = make(map[string]*sync.Mutex)
38
+
39
+	// A package level mutex to synchronize the cache itself
40
+	pathMutex sync.Mutex
41
+)
42
+
43
+func pathLock(path string) func() {
44
+	pathMutex.Lock()
45
+	defer pathMutex.Unlock()
46
+
47
+	pl, ok := pathMap[path]
48
+	if !ok {
49
+		pl = &sync.Mutex{}
50
+		pathMap[path] = pl
51
+	}
52
+
53
+	pl.Lock()
54
+	return func() {
55
+		pl.Unlock()
56
+	}
57
+}
58
+
59
+// Drop drops the path string from the path cache
60
+func Drop(path string) {
61
+	pathMutex.Lock()
62
+	defer pathMutex.Unlock()
63
+
64
+	delete(pathMap, path)
32 65
 }
33 66
 
34 67
 // Build function
... ...
@@ -36,6 +70,8 @@ var defaultContent = []Record{
36 36
 // IP, hostname, and domainname set main record leave empty for no master record
37 37
 // extraContent is an array of extra host records.
38 38
 func Build(path, IP, hostname, domainname string, extraContent []Record) error {
39
+	defer pathLock(path)()
40
+
39 41
 	content := bytes.NewBuffer(nil)
40 42
 	if IP != "" {
41 43
 		//set main record
... ...
@@ -68,6 +104,8 @@ func Build(path, IP, hostname, domainname string, extraContent []Record) error {
68 68
 
69 69
 // Add adds an arbitrary number of Records to an already existing /etc/hosts file
70 70
 func Add(path string, recs []Record) error {
71
+	defer pathLock(path)()
72
+
71 73
 	if len(recs) == 0 {
72 74
 		return nil
73 75
 	}
... ...
@@ -95,6 +133,8 @@ func Add(path string, recs []Record) error {
95 95
 
96 96
 // Delete deletes an arbitrary number of Records already existing in /etc/hosts file
97 97
 func Delete(path string, recs []Record) error {
98
+	defer pathLock(path)()
99
+
98 100
 	if len(recs) == 0 {
99 101
 		return nil
100 102
 	}
... ...
@@ -118,6 +158,8 @@ func Delete(path string, recs []Record) error {
118 118
 // IP is new IP address
119 119
 // hostname is hostname to search for to replace IP
120 120
 func Update(path, IP, hostname string) error {
121
+	defer pathLock(path)()
122
+
121 123
 	old, err := ioutil.ReadFile(path)
122 124
 	if err != nil {
123 125
 		return err
... ...
@@ -2,8 +2,10 @@ package etchosts
2 2
 
3 3
 import (
4 4
 	"bytes"
5
+	"fmt"
5 6
 	"io/ioutil"
6 7
 	"os"
8
+	"sync"
7 9
 	"testing"
8 10
 
9 11
 	_ "github.com/docker/libnetwork/testutils"
... ...
@@ -247,3 +249,61 @@ func TestDelete(t *testing.T) {
247 247
 		t.Fatalf("Did not expect to find '%s' got '%s'", expected, content)
248 248
 	}
249 249
 }
250
+
251
+func TestConcurrentWrites(t *testing.T) {
252
+	file, err := ioutil.TempFile("", "")
253
+	if err != nil {
254
+		t.Fatal(err)
255
+	}
256
+	defer os.Remove(file.Name())
257
+
258
+	err = Build(file.Name(), "", "", "", nil)
259
+	if err != nil {
260
+		t.Fatal(err)
261
+	}
262
+
263
+	if err := Add(file.Name(), []Record{
264
+		Record{
265
+			Hosts: "inithostname",
266
+			IP:    "172.17.0.1",
267
+		},
268
+	}); err != nil {
269
+		t.Fatal(err)
270
+	}
271
+
272
+	var wg sync.WaitGroup
273
+	for i := 0; i < 10; i++ {
274
+		wg.Add(1)
275
+		go func() {
276
+			defer wg.Done()
277
+
278
+			rec := []Record{
279
+				Record{
280
+					IP:    fmt.Sprintf("%d.%d.%d.%d", i, i, i, i),
281
+					Hosts: fmt.Sprintf("testhostname%d", i),
282
+				},
283
+			}
284
+
285
+			for j := 0; j < 25; j++ {
286
+				if err := Add(file.Name(), rec); err != nil {
287
+					t.Fatal(err)
288
+				}
289
+
290
+				if err := Delete(file.Name(), rec); err != nil {
291
+					t.Fatal(err)
292
+				}
293
+			}
294
+		}()
295
+	}
296
+
297
+	wg.Wait()
298
+
299
+	content, err := ioutil.ReadFile(file.Name())
300
+	if err != nil {
301
+		t.Fatal(err)
302
+	}
303
+
304
+	if expected := "172.17.0.1\tinithostname\n"; !bytes.Contains(content, []byte(expected)) {
305
+		t.Fatalf("Expected to find '%s' got '%s'", expected, content)
306
+	}
307
+}
... ...
@@ -182,6 +182,10 @@ func (sb *sandbox) Delete() error {
182 182
 		}
183 183
 	}
184 184
 
185
+	// Container is going away. Path cache in etchosts is most
186
+	// likely not required any more. Drop it.
187
+	etchosts.Drop(sb.config.hostsPath)
188
+
185 189
 	if sb.osSbox != nil {
186 190
 		sb.osSbox.Destroy()
187 191
 	}