Repository: moby/moby (language: go)

Issue:
Add exec option to tmpfs
This PR adds the ability to handle exec option on tmpfs mounts. 

- fixes #32131 
- fixes https://github.com/moby/moby/issues/45385

Notes:

This is the first draft. As discussed here: https://github.com/moby/moby/issues/32131#issuecomment-375074893, I have included the changes on `docker/swarmkit` types that will need vendoring. I will submit PR(s) on `docker/swarmkit` and `docker/cli` once if this PR is approved.




**- What I did**
Feature: allow specifying exec option for tmpfs mounts

**- How I did it**
Added the required code and tests to pkg/mount, volume, api/types, and daemon.

**- How to verify it**
I have added an integration test for verification. 

**- Description for the changelog**
<!--
Write a short (one line) summary that describes the changes in this
pull request for inclusion in the changelog:
-->


**- A picture of a cute animal (not mandatory but encouraged)**




Discussion / hints:

Fix patch:
diff --git a/api/server/router/swarm/helpers.go b/api/server/router/swarm/helpers.go
index 816ba7c9fa4db..753a94951bb92 100644
--- a/api/server/router/swarm/helpers.go
+++ b/api/server/router/swarm/helpers.go
@@ -78,6 +78,16 @@ func adjustForAPIVersion(cliVersion string, service *swarm.ServiceSpec) {
 	if cliVersion == "" {
 		return
 	}
+	if versions.LessThan(cliVersion, "1.46") {
+		if service.TaskTemplate.ContainerSpec != nil {
+			for i, mount := range service.TaskTemplate.ContainerSpec.Mounts {
+				if mount.TmpfsOptions != nil {
+					mount.TmpfsOptions.Options = nil
+					service.TaskTemplate.ContainerSpec.Mounts[i] = mount
+				}
+			}
+		}
+	}
 	if versions.LessThan(cliVersion, "1.40") {
 		if service.TaskTemplate.ContainerSpec != nil {
 			// Sysctls for docker swarm services weren't supported before
diff --git a/api/swagger.yaml b/api/swagger.yaml
index f1e57756e3365..2f33afeff92e1 100644
--- a/api/swagger.yaml
+++ b/api/swagger.yaml
@@ -442,6 +442,24 @@ definitions:
           Mode:
             description: "The permission mode for the tmpfs mount in an integer."
             type: "integer"
+          Options:
+            description: |
+              The options to be passed to the tmpfs mount. An array of arrays.
+              Flag options should be provided as 1-length arrays. Other types
+              should be provided as as 2-length arrays, where the first item is
+              the key and the second the value.
+            type: "array"
+            properties:
+              items:
+                type: "array"
+                items:
+                  type: "array"
+                  minItems: 1
+                  maxItems: 2
+                  items:
+                    type: "string"
+            example:
+              [["noexec"]]
 
   RestartPolicy:
     description: |
diff --git a/api/types/mount/mount.go b/api/types/mount/mount.go
index 6fe04da257942..c68dcf65bd12d 100644
--- a/api/types/mount/mount.go
+++ b/api/types/mount/mount.go
@@ -119,7 +119,11 @@ type TmpfsOptions struct {
 	SizeBytes int64 `json:",omitempty"`
 	// Mode of the tmpfs upon creation
 	Mode os.FileMode `json:",omitempty"`
-
+	// Options to be passed to the tmpfs mount. An array of arrays. Flag
+	// options should be provided as 1-length arrays. Other types should be
+	// provided as 2-length arrays, where the first item is the key and the
+	// second the value.
+	Options [][]string `json:",omitempty"`
 	// TODO(stevvooe): There are several more tmpfs flags, specified in the
 	// daemon, that are accepted. Only the most basic are added for now.
 	//
diff --git a/daemon/cluster/convert/container.go b/daemon/cluster/convert/container.go
index b6b610e10e5d1..717a133f13627 100644
--- a/daemon/cluster/convert/container.go
+++ b/daemon/cluster/convert/container.go
@@ -2,6 +2,7 @@ package convert // import "github.com/docker/docker/daemon/cluster/convert"
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -136,6 +137,7 @@ func containerSpecFromGRPC(c *swarmapi.ContainerSpec) *types.ContainerSpec {
 			mount.TmpfsOptions = &mounttypes.TmpfsOptions{
 				SizeBytes: m.TmpfsOptions.SizeBytes,
 				Mode:      m.TmpfsOptions.Mode,
+				Options:   tmpfsOptionsFromGRPC(m.TmpfsOptions.Options),
 			}
 		}
 		containerSpec.Mounts = append(containerSpec.Mounts, mount)
@@ -423,6 +425,7 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) {
 			mount.TmpfsOptions = &swarmapi.Mount_TmpfsOptions{
 				SizeBytes: m.TmpfsOptions.SizeBytes,
 				Mode:      m.TmpfsOptions.Mode,
+				Options:   tmpfsOptionsToGRPC(m.TmpfsOptions.Options),
 			}
 		}
 
@@ -566,3 +569,32 @@ func ulimitsToGRPC(u []*units.Ulimit) []*swarmapi.ContainerSpec_Ulimit {
 
 	return ulimits
 }
+
+func tmpfsOptionsToGRPC(options [][]string) string {
+	// The shape of the swarmkit API that tmpfs options are a string. The shape
+	// of the docker API has them as a more structured array of arrays of
+	// strings. To smooth this over, we will marshall the array-of-arrays to
+	// json then pass that as the string.
+
+	// Marshalling json can create an error, but only in specific cases which
+	// are not relevant. We can ignore the possibility.
+	jsonBytes, _ := json.Marshal(options)
+	return string(jsonBytes)
+}
+
+func tmpfsOptionsFromGRPC(options string) [][]string {
+	// See tmpfsOptionsToGRPC for the reasoning. We undo what we did.
+	var unstring [][]string
+	// We can't return errors from here, so just don't ever pass anything that
+	// could result in an error.
+	//
+	// Duh.
+	//
+	// If there is something erroneous, then an empty return value will result,
+	// which should not be catastrophic. Because we control the data that is
+	// marshalled (in tmpfsOptionsToGRPC), we can more-or-less ensure that only
+	// valid data is unmarshalled here. If someone does something like muck
+	// with the GRPC API directly, then they get footgun, no apologies.
+	_ = json.Unmarshal([]byte(options), &unstring)
+	return unstring
+}
diff --git a/daemon/cluster/executor/container/container.go b/daemon/cluster/executor/container/container.go
index dfab6c122c6e4..3cfc3f2b68102 100644
--- a/daemon/cluster/executor/container/container.go
+++ b/daemon/cluster/executor/container/container.go
@@ -2,6 +2,7 @@ package container // import "github.com/docker/docker/daemon/cluster/executor/co
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net"
@@ -360,9 +361,14 @@ func convertMount(m api.Mount) enginemount.Mount {
 	}
 
 	if m.TmpfsOptions != nil {
+		var options [][]string
+		// see daemon/cluster/convert/container.go, tmpfsOptionsFromGRPC for
+		// details on error handling.
+		_ = json.Unmarshal([]byte(m.TmpfsOptions.Options), &options)
 		mount.TmpfsOptions = &enginemount.TmpfsOptions{
 			SizeBytes: m.TmpfsOptions.SizeBytes,
 			Mode:      m.TmpfsOptions.Mode,
+			Options:   options,
 		}
 	}
 
diff --git a/docs/api/version-history.md b/docs/api/version-history.md
index f4b3e1a1fd82a..d2b771bfcb999 100644
--- a/docs/api/version-history.md
+++ b/docs/api/version-history.md
@@ -30,6 +30,8 @@ keywords: "API, Docker, rcli, REST, documentation"
 * `POST /images/{name}/push` now supports a `platform` parameter (JSON encoded
   OCI Platform type) that allows selecting a specific platform manifest from
   the multi-platform image.
+* `POST /containers/create` now takes `Options` as part of `HostConfig.Mounts.TmpfsOptions` to set options for tmpfs mounts.
+* `POST /services/create` now takes `Options` as part of `ContainerSpec.Mounts.TmpfsOptions`, to set options for tmpfs mounts.
 
 ### Deprecated Config fields in `GET /images/{name}/json` response
 
diff --git a/volume/mounts/linux_parser.go b/volume/mounts/linux_parser.go
index 1532187c773a5..898661a574c9a 100644
--- a/volume/mounts/linux_parser.go
+++ b/volume/mounts/linux_parser.go
@@ -204,6 +204,30 @@ func linuxValidMountMode(mode string) bool {
 	return true
 }
 
+var validTmpfsOptions = map[string]bool{
+	"exec":   true,
+	"noexec": true,
+}
+
+func validateTmpfsOptions(rawOptions [][]string) ([]string, error) {
+	var options []string
+	for _, opt := range rawOptions {
+		if len(opt) < 1 || len(opt) > 2 {
+			return nil, errors.New("invalid option array length")
+		}
+		if _, ok := validTmpfsOptions[opt[0]]; !ok {
+			return nil, errors.New("invalid option: " + opt[0])
+		}
+
+		if len(opt) == 1 {
+			options = append(options, opt[0])
+		} else {
+			options = append(options, fmt.Sprintf("%s=%s", opt[0], opt[1]))
+		}
+	}
+	return options, nil
+}
+
 func (p *linuxParser) ReadWrite(mode string) bool {
 	if !linuxValidMountMode(mode) {
 		return false
@@ -406,6 +430,15 @@ func (p *linuxParser) ConvertTmpfsOptions(opt *mount.TmpfsOptions, readOnly bool
 
 		rawOpts = append(rawOpts, fmt.Sprintf("size=%d%s", size, suffix))
 	}
+
+	if opt != nil && len(opt.Options) > 0 {
+		tmpfsOpts, err := validateTmpfsOptions(opt.Options)
+		if err != nil {
+			return "", err
+		}
+		rawOpts = append(rawOpts, tmpfsOpts...)
+	}
+
 	return strings.Join(rawOpts, ","), nil
 }
 


Test patch:
diff --git a/api/server/router/swarm/helpers_test.go b/api/server/router/swarm/helpers_test.go
index 3b8ed71e0ae53..24af7c18b0228 100644
--- a/api/server/router/swarm/helpers_test.go
+++ b/api/server/router/swarm/helpers_test.go
@@ -4,6 +4,7 @@ import (
 	"reflect"
 	"testing"
 
+	"github.com/docker/docker/api/types/mount"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/go-units"
 )
@@ -45,6 +46,18 @@ func TestAdjustForAPIVersion(t *testing.T) {
 						Hard: 200,
 					},
 				},
+				Mounts: []mount.Mount{
+					{
+						Type:   mount.TypeTmpfs,
+						Source: "/foo",
+						Target: "/bar",
+						TmpfsOptions: &mount.TmpfsOptions{
+							Options: [][]string{
+								[]string{"exec"},
+							},
+						},
+					},
+				},
 			},
 			Placement: &swarm.Placement{
 				MaxReplicas: 222,
@@ -57,6 +70,19 @@ func TestAdjustForAPIVersion(t *testing.T) {
 		},
 	}
 
+	adjustForAPIVersion("1.46", spec)
+	if !reflect.DeepEqual(
+		spec.TaskTemplate.ContainerSpec.Mounts[0].TmpfsOptions.Options,
+		[][]string{[]string{"exec"}},
+	) {
+		t.Error("TmpfsOptions.Options was stripped from spec")
+	}
+
+	adjustForAPIVersion("1.45", spec)
+	if len(spec.TaskTemplate.ContainerSpec.Mounts[0].TmpfsOptions.Options) != 0 {
+		t.Error("TmpfsOptions.Options not stripped from spec")
+	}
+
 	// first, does calling this with a later version correctly NOT strip
 	// fields? do the later version first, so we can reuse this spec in the
 	// next test.
diff --git a/daemon/cluster/convert/container_test.go b/daemon/cluster/convert/container_test.go
new file mode 100644
index 0000000000000..f3886f71a7b90
--- /dev/null
+++ b/daemon/cluster/convert/container_test.go
@@ -0,0 +1,30 @@
+package convert // import "github.com/docker/docker/daemon/cluster/convert"
+
+import (
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+func TestTmpfsOptionsToGRPC(t *testing.T) {
+	options := [][]string{
+		[]string{"noexec"},
+		[]string{"uid", "12345"},
+	}
+
+	expected := `[["noexec"],["uid","12345"]]`
+	actual := tmpfsOptionsToGRPC(options)
+	assert.Equal(t, expected, actual)
+}
+
+func TestTmpfsOptionsFromGRPC(t *testing.T) {
+	options := `[["noexec"],["uid","12345"]]`
+
+	expected := [][]string{
+		[]string{"noexec"},
+		[]string{"uid", "12345"},
+	}
+	actual := tmpfsOptionsFromGRPC(options)
+
+	assert.DeepEqual(t, expected, actual)
+}
diff --git a/daemon/cluster/executor/container/container_test.go b/daemon/cluster/executor/container/container_test.go
index 8055e111fe180..25cdc335d2711 100644
--- a/daemon/cluster/executor/container/container_test.go
+++ b/daemon/cluster/executor/container/container_test.go
@@ -4,8 +4,10 @@ import (
 	"testing"
 
 	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/mount"
 	swarmapi "github.com/moby/swarmkit/v2/api"
 	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
 func TestIsolationConversion(t *testing.T) {
@@ -117,6 +119,7 @@ func TestCredentialSpecConversion(t *testing.T) {
 			to: []string{"credentialspec=registry://testing"},
 		},
 	}
+
 	for _, c := range cases {
 		c := c
 		t.Run(c.name, func(t *testing.T) {
@@ -139,3 +142,75 @@ func TestCredentialSpecConversion(t *testing.T) {
 		})
 	}
 }
+
+func TestTmpfsConversion(t *testing.T) {
+	cases := []struct {
+		name string
+		from []swarmapi.Mount
+		to   []mount.Mount
+	}{
+		{
+			name: "tmpfs-exec",
+			from: []swarmapi.Mount{
+				{
+					Source: "/foo",
+					Target: "/bar",
+					Type:   swarmapi.MountTypeTmpfs,
+					TmpfsOptions: &swarmapi.Mount_TmpfsOptions{
+						Options: "[[\"exec\"]]",
+					},
+				},
+			},
+			to: []mount.Mount{
+				{
+					Source: "/foo",
+					Target: "/bar",
+					Type:   mount.TypeTmpfs,
+					TmpfsOptions: &mount.TmpfsOptions{
+						Options: [][]string{[]string{"exec"}},
+					},
+				},
+			},
+		},
+		{
+			name: "tmpfs-noexec",
+			from: []swarmapi.Mount{
+				{
+					Source: "/foo",
+					Target: "/bar",
+					Type:   swarmapi.MountTypeTmpfs,
+					TmpfsOptions: &swarmapi.Mount_TmpfsOptions{
+						Options: "[[\"noexec\"]]",
+					},
+				},
+			},
+			to: []mount.Mount{
+				{
+					Source: "/foo",
+					Target: "/bar",
+					Type:   mount.TypeTmpfs,
+					TmpfsOptions: &mount.TmpfsOptions{
+						Options: [][]string{[]string{"noexec"}},
+					},
+				},
+			},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			task := swarmapi.Task{
+				Spec: swarmapi.TaskSpec{
+					Runtime: &swarmapi.TaskSpec_Container{
+						Container: &swarmapi.ContainerSpec{
+							Image:  "alpine:latest",
+							Mounts: c.from,
+						},
+					},
+				},
+			}
+			config := containerConfig{task: &task}
+			assert.Check(t, is.DeepEqual(c.to, config.hostConfig(nil).Mounts))
+		})
+	}
+}
diff --git a/volume/mounts/linux_parser_test.go b/volume/mounts/linux_parser_test.go
index d4c7a3856e1ab..20c9fbc18b063 100644
--- a/volume/mounts/linux_parser_test.go
+++ b/volume/mounts/linux_parser_test.go
@@ -238,6 +238,7 @@ func TestConvertTmpfsOptions(t *testing.T) {
 		readOnly             bool
 		expectedSubstrings   []string
 		unexpectedSubstrings []string
+		err                  bool
 	}
 	cases := []testCase{
 		{
@@ -252,10 +253,26 @@ func TestConvertTmpfsOptions(t *testing.T) {
 			expectedSubstrings:   []string{"ro"},
 			unexpectedSubstrings: []string{},
 		},
+		{
+			opt:                  mount.TmpfsOptions{Options: [][]string{{"exec"}}},
+			readOnly:             true,
+			expectedSubstrings:   []string{"ro", "exec"},
+			unexpectedSubstrings: []string{"noexec"},
+		},
+		{
+			opt: mount.TmpfsOptions{Options: [][]string{{"INVALID"}}},
+			err: true,
+		},
 	}
 	p := NewLinuxParser()
 	for _, tc := range cases {
 		data, err := p.ConvertTmpfsOptions(&tc.opt, tc.readOnly)
+		if tc.err {
+			if err == nil {
+				t.Fatalf("expected error for %+v, got nil", tc.opt)
+			}
+			continue
+		}
 		if err != nil {
 			t.Fatalf("could not convert %+v (readOnly: %v) to string: %v",
 				tc.opt, tc.readOnly, err)