Browse code

Add exec option to API TmpfsOptions

Includes two commits from Arash Deshmeh:

add exec option to API TmpfsOptions and the related volume functions

Signed-off-by: Arash Deshmeh <adeshmeh@ca.ibm.com>

feature: daemon handles tmpfs mounts exec option

Signed-off-by: Arash Deshmeh <adeshmeh@ca.ibm.com>

Updated by Drew Erny

Signed-off-by: Drew Erny <derny@mirantis.com>

Arash Deshmeh authored on 2018/03/25 10:43:57
Showing 11 changed files
... ...
@@ -78,6 +78,16 @@ func adjustForAPIVersion(cliVersion string, service *swarm.ServiceSpec) {
78 78
 	if cliVersion == "" {
79 79
 		return
80 80
 	}
81
+	if versions.LessThan(cliVersion, "1.46") {
82
+		if service.TaskTemplate.ContainerSpec != nil {
83
+			for i, mount := range service.TaskTemplate.ContainerSpec.Mounts {
84
+				if mount.TmpfsOptions != nil {
85
+					mount.TmpfsOptions.Options = nil
86
+					service.TaskTemplate.ContainerSpec.Mounts[i] = mount
87
+				}
88
+			}
89
+		}
90
+	}
81 91
 	if versions.LessThan(cliVersion, "1.40") {
82 92
 		if service.TaskTemplate.ContainerSpec != nil {
83 93
 			// Sysctls for docker swarm services weren't supported before
... ...
@@ -4,6 +4,7 @@ import (
4 4
 	"reflect"
5 5
 	"testing"
6 6
 
7
+	"github.com/docker/docker/api/types/mount"
7 8
 	"github.com/docker/docker/api/types/swarm"
8 9
 	"github.com/docker/go-units"
9 10
 )
... ...
@@ -45,6 +46,18 @@ func TestAdjustForAPIVersion(t *testing.T) {
45 45
 						Hard: 200,
46 46
 					},
47 47
 				},
48
+				Mounts: []mount.Mount{
49
+					{
50
+						Type:   mount.TypeTmpfs,
51
+						Source: "/foo",
52
+						Target: "/bar",
53
+						TmpfsOptions: &mount.TmpfsOptions{
54
+							Options: [][]string{
55
+								[]string{"exec"},
56
+							},
57
+						},
58
+					},
59
+				},
48 60
 			},
49 61
 			Placement: &swarm.Placement{
50 62
 				MaxReplicas: 222,
... ...
@@ -57,6 +70,19 @@ func TestAdjustForAPIVersion(t *testing.T) {
57 57
 		},
58 58
 	}
59 59
 
60
+	adjustForAPIVersion("1.46", spec)
61
+	if !reflect.DeepEqual(
62
+		spec.TaskTemplate.ContainerSpec.Mounts[0].TmpfsOptions.Options,
63
+		[][]string{[]string{"exec"}},
64
+	) {
65
+		t.Error("TmpfsOptions.Options was stripped from spec")
66
+	}
67
+
68
+	adjustForAPIVersion("1.45", spec)
69
+	if len(spec.TaskTemplate.ContainerSpec.Mounts[0].TmpfsOptions.Options) != 0 {
70
+		t.Error("TmpfsOptions.Options not stripped from spec")
71
+	}
72
+
60 73
 	// first, does calling this with a later version correctly NOT strip
61 74
 	// fields? do the later version first, so we can reuse this spec in the
62 75
 	// next test.
... ...
@@ -442,6 +442,24 @@ definitions:
442 442
           Mode:
443 443
             description: "The permission mode for the tmpfs mount in an integer."
444 444
             type: "integer"
445
+          Options:
446
+            description: |
447
+              The options to be passed to the tmpfs mount. An array of arrays.
448
+              Flag options should be provided as 1-length arrays. Other types
449
+              should be provided as as 2-length arrays, where the first item is
450
+              the key and the second the value.
451
+            type: "array"
452
+            properties:
453
+              items:
454
+                type: "array"
455
+                items:
456
+                  type: "array"
457
+                  minItems: 1
458
+                  maxItems: 2
459
+                  items:
460
+                    type: "string"
461
+            example:
462
+              [["noexec"]]
445 463
 
446 464
   RestartPolicy:
447 465
     description: |
... ...
@@ -119,7 +119,11 @@ type TmpfsOptions struct {
119 119
 	SizeBytes int64 `json:",omitempty"`
120 120
 	// Mode of the tmpfs upon creation
121 121
 	Mode os.FileMode `json:",omitempty"`
122
-
122
+	// Options to be passed to the tmpfs mount. An array of arrays. Flag
123
+	// options should be provided as 1-length arrays. Other types should be
124
+	// provided as 2-length arrays, where the first item is the key and the
125
+	// second the value.
126
+	Options [][]string `json:",omitempty"`
123 127
 	// TODO(stevvooe): There are several more tmpfs flags, specified in the
124 128
 	// daemon, that are accepted. Only the most basic are added for now.
125 129
 	//
... ...
@@ -2,6 +2,7 @@ package convert // import "github.com/docker/docker/daemon/cluster/convert"
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"encoding/json"
5 6
 	"fmt"
6 7
 	"strings"
7 8
 
... ...
@@ -136,6 +137,7 @@ func containerSpecFromGRPC(c *swarmapi.ContainerSpec) *types.ContainerSpec {
136 136
 			mount.TmpfsOptions = &mounttypes.TmpfsOptions{
137 137
 				SizeBytes: m.TmpfsOptions.SizeBytes,
138 138
 				Mode:      m.TmpfsOptions.Mode,
139
+				Options:   tmpfsOptionsFromGRPC(m.TmpfsOptions.Options),
139 140
 			}
140 141
 		}
141 142
 		containerSpec.Mounts = append(containerSpec.Mounts, mount)
... ...
@@ -423,6 +425,7 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) {
423 423
 			mount.TmpfsOptions = &swarmapi.Mount_TmpfsOptions{
424 424
 				SizeBytes: m.TmpfsOptions.SizeBytes,
425 425
 				Mode:      m.TmpfsOptions.Mode,
426
+				Options:   tmpfsOptionsToGRPC(m.TmpfsOptions.Options),
426 427
 			}
427 428
 		}
428 429
 
... ...
@@ -566,3 +569,32 @@ func ulimitsToGRPC(u []*units.Ulimit) []*swarmapi.ContainerSpec_Ulimit {
566 566
 
567 567
 	return ulimits
568 568
 }
569
+
570
+func tmpfsOptionsToGRPC(options [][]string) string {
571
+	// The shape of the swarmkit API that tmpfs options are a string. The shape
572
+	// of the docker API has them as a more structured array of arrays of
573
+	// strings. To smooth this over, we will marshall the array-of-arrays to
574
+	// json then pass that as the string.
575
+
576
+	// Marshalling json can create an error, but only in specific cases which
577
+	// are not relevant. We can ignore the possibility.
578
+	jsonBytes, _ := json.Marshal(options)
579
+	return string(jsonBytes)
580
+}
581
+
582
+func tmpfsOptionsFromGRPC(options string) [][]string {
583
+	// See tmpfsOptionsToGRPC for the reasoning. We undo what we did.
584
+	var unstring [][]string
585
+	// We can't return errors from here, so just don't ever pass anything that
586
+	// could result in an error.
587
+	//
588
+	// Duh.
589
+	//
590
+	// If there is something erroneous, then an empty return value will result,
591
+	// which should not be catastrophic. Because we control the data that is
592
+	// marshalled (in tmpfsOptionsToGRPC), we can more-or-less ensure that only
593
+	// valid data is unmarshalled here. If someone does something like muck
594
+	// with the GRPC API directly, then they get footgun, no apologies.
595
+	_ = json.Unmarshal([]byte(options), &unstring)
596
+	return unstring
597
+}
569 598
new file mode 100644
... ...
@@ -0,0 +1,30 @@
0
+package convert // import "github.com/docker/docker/daemon/cluster/convert"
1
+
2
+import (
3
+	"testing"
4
+
5
+	"gotest.tools/v3/assert"
6
+)
7
+
8
+func TestTmpfsOptionsToGRPC(t *testing.T) {
9
+	options := [][]string{
10
+		[]string{"noexec"},
11
+		[]string{"uid", "12345"},
12
+	}
13
+
14
+	expected := `[["noexec"],["uid","12345"]]`
15
+	actual := tmpfsOptionsToGRPC(options)
16
+	assert.Equal(t, expected, actual)
17
+}
18
+
19
+func TestTmpfsOptionsFromGRPC(t *testing.T) {
20
+	options := `[["noexec"],["uid","12345"]]`
21
+
22
+	expected := [][]string{
23
+		[]string{"noexec"},
24
+		[]string{"uid", "12345"},
25
+	}
26
+	actual := tmpfsOptionsFromGRPC(options)
27
+
28
+	assert.DeepEqual(t, expected, actual)
29
+}
... ...
@@ -2,6 +2,7 @@ package container // import "github.com/docker/docker/daemon/cluster/executor/co
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"encoding/json"
5 6
 	"errors"
6 7
 	"fmt"
7 8
 	"net"
... ...
@@ -360,9 +361,14 @@ func convertMount(m api.Mount) enginemount.Mount {
360 360
 	}
361 361
 
362 362
 	if m.TmpfsOptions != nil {
363
+		var options [][]string
364
+		// see daemon/cluster/convert/container.go, tmpfsOptionsFromGRPC for
365
+		// details on error handling.
366
+		_ = json.Unmarshal([]byte(m.TmpfsOptions.Options), &options)
363 367
 		mount.TmpfsOptions = &enginemount.TmpfsOptions{
364 368
 			SizeBytes: m.TmpfsOptions.SizeBytes,
365 369
 			Mode:      m.TmpfsOptions.Mode,
370
+			Options:   options,
366 371
 		}
367 372
 	}
368 373
 
... ...
@@ -4,8 +4,10 @@ import (
4 4
 	"testing"
5 5
 
6 6
 	"github.com/docker/docker/api/types/container"
7
+	"github.com/docker/docker/api/types/mount"
7 8
 	swarmapi "github.com/moby/swarmkit/v2/api"
8 9
 	"gotest.tools/v3/assert"
10
+	is "gotest.tools/v3/assert/cmp"
9 11
 )
10 12
 
11 13
 func TestIsolationConversion(t *testing.T) {
... ...
@@ -117,6 +119,7 @@ func TestCredentialSpecConversion(t *testing.T) {
117 117
 			to: []string{"credentialspec=registry://testing"},
118 118
 		},
119 119
 	}
120
+
120 121
 	for _, c := range cases {
121 122
 		c := c
122 123
 		t.Run(c.name, func(t *testing.T) {
... ...
@@ -139,3 +142,75 @@ func TestCredentialSpecConversion(t *testing.T) {
139 139
 		})
140 140
 	}
141 141
 }
142
+
143
+func TestTmpfsConversion(t *testing.T) {
144
+	cases := []struct {
145
+		name string
146
+		from []swarmapi.Mount
147
+		to   []mount.Mount
148
+	}{
149
+		{
150
+			name: "tmpfs-exec",
151
+			from: []swarmapi.Mount{
152
+				{
153
+					Source: "/foo",
154
+					Target: "/bar",
155
+					Type:   swarmapi.MountTypeTmpfs,
156
+					TmpfsOptions: &swarmapi.Mount_TmpfsOptions{
157
+						Options: "[[\"exec\"]]",
158
+					},
159
+				},
160
+			},
161
+			to: []mount.Mount{
162
+				{
163
+					Source: "/foo",
164
+					Target: "/bar",
165
+					Type:   mount.TypeTmpfs,
166
+					TmpfsOptions: &mount.TmpfsOptions{
167
+						Options: [][]string{[]string{"exec"}},
168
+					},
169
+				},
170
+			},
171
+		},
172
+		{
173
+			name: "tmpfs-noexec",
174
+			from: []swarmapi.Mount{
175
+				{
176
+					Source: "/foo",
177
+					Target: "/bar",
178
+					Type:   swarmapi.MountTypeTmpfs,
179
+					TmpfsOptions: &swarmapi.Mount_TmpfsOptions{
180
+						Options: "[[\"noexec\"]]",
181
+					},
182
+				},
183
+			},
184
+			to: []mount.Mount{
185
+				{
186
+					Source: "/foo",
187
+					Target: "/bar",
188
+					Type:   mount.TypeTmpfs,
189
+					TmpfsOptions: &mount.TmpfsOptions{
190
+						Options: [][]string{[]string{"noexec"}},
191
+					},
192
+				},
193
+			},
194
+		},
195
+	}
196
+
197
+	for _, c := range cases {
198
+		t.Run(c.name, func(t *testing.T) {
199
+			task := swarmapi.Task{
200
+				Spec: swarmapi.TaskSpec{
201
+					Runtime: &swarmapi.TaskSpec_Container{
202
+						Container: &swarmapi.ContainerSpec{
203
+							Image:  "alpine:latest",
204
+							Mounts: c.from,
205
+						},
206
+					},
207
+				},
208
+			}
209
+			config := containerConfig{task: &task}
210
+			assert.Check(t, is.DeepEqual(c.to, config.hostConfig(nil).Mounts))
211
+		})
212
+	}
213
+}
... ...
@@ -30,6 +30,8 @@ keywords: "API, Docker, rcli, REST, documentation"
30 30
 * `POST /images/{name}/push` now supports a `platform` parameter (JSON encoded
31 31
   OCI Platform type) that allows selecting a specific platform manifest from
32 32
   the multi-platform image.
33
+* `POST /containers/create` now takes `Options` as part of `HostConfig.Mounts.TmpfsOptions` to set options for tmpfs mounts.
34
+* `POST /services/create` now takes `Options` as part of `ContainerSpec.Mounts.TmpfsOptions`, to set options for tmpfs mounts.
33 35
 
34 36
 ### Deprecated Config fields in `GET /images/{name}/json` response
35 37
 
... ...
@@ -204,6 +204,30 @@ func linuxValidMountMode(mode string) bool {
204 204
 	return true
205 205
 }
206 206
 
207
+var validTmpfsOptions = map[string]bool{
208
+	"exec":   true,
209
+	"noexec": true,
210
+}
211
+
212
+func validateTmpfsOptions(rawOptions [][]string) ([]string, error) {
213
+	var options []string
214
+	for _, opt := range rawOptions {
215
+		if len(opt) < 1 || len(opt) > 2 {
216
+			return nil, errors.New("invalid option array length")
217
+		}
218
+		if _, ok := validTmpfsOptions[opt[0]]; !ok {
219
+			return nil, errors.New("invalid option: " + opt[0])
220
+		}
221
+
222
+		if len(opt) == 1 {
223
+			options = append(options, opt[0])
224
+		} else {
225
+			options = append(options, fmt.Sprintf("%s=%s", opt[0], opt[1]))
226
+		}
227
+	}
228
+	return options, nil
229
+}
230
+
207 231
 func (p *linuxParser) ReadWrite(mode string) bool {
208 232
 	if !linuxValidMountMode(mode) {
209 233
 		return false
... ...
@@ -406,6 +430,15 @@ func (p *linuxParser) ConvertTmpfsOptions(opt *mount.TmpfsOptions, readOnly bool
406 406
 
407 407
 		rawOpts = append(rawOpts, fmt.Sprintf("size=%d%s", size, suffix))
408 408
 	}
409
+
410
+	if opt != nil && len(opt.Options) > 0 {
411
+		tmpfsOpts, err := validateTmpfsOptions(opt.Options)
412
+		if err != nil {
413
+			return "", err
414
+		}
415
+		rawOpts = append(rawOpts, tmpfsOpts...)
416
+	}
417
+
409 418
 	return strings.Join(rawOpts, ","), nil
410 419
 }
411 420
 
... ...
@@ -238,6 +238,7 @@ func TestConvertTmpfsOptions(t *testing.T) {
238 238
 		readOnly             bool
239 239
 		expectedSubstrings   []string
240 240
 		unexpectedSubstrings []string
241
+		err                  bool
241 242
 	}
242 243
 	cases := []testCase{
243 244
 		{
... ...
@@ -252,10 +253,26 @@ func TestConvertTmpfsOptions(t *testing.T) {
252 252
 			expectedSubstrings:   []string{"ro"},
253 253
 			unexpectedSubstrings: []string{},
254 254
 		},
255
+		{
256
+			opt:                  mount.TmpfsOptions{Options: [][]string{{"exec"}}},
257
+			readOnly:             true,
258
+			expectedSubstrings:   []string{"ro", "exec"},
259
+			unexpectedSubstrings: []string{"noexec"},
260
+		},
261
+		{
262
+			opt: mount.TmpfsOptions{Options: [][]string{{"INVALID"}}},
263
+			err: true,
264
+		},
255 265
 	}
256 266
 	p := NewLinuxParser()
257 267
 	for _, tc := range cases {
258 268
 		data, err := p.ConvertTmpfsOptions(&tc.opt, tc.readOnly)
269
+		if tc.err {
270
+			if err == nil {
271
+				t.Fatalf("expected error for %+v, got nil", tc.opt)
272
+			}
273
+			continue
274
+		}
259 275
 		if err != nil {
260 276
 			t.Fatalf("could not convert %+v (readOnly: %v) to string: %v",
261 277
 				tc.opt, tc.readOnly, err)