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>
| ... | ... |
@@ -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) |