Browse code

Use Runtime target

The Swarmkit api specifies a target for configs called called "Runtime"
which indicates that the config is not mounted into the container but
has some other use. This commit updates the Docker api to reflect this.

Signed-off-by: Drew Erny <drew.erny@docker.com>

Drew Erny authored on 2019/02/08 05:27:08
Showing 11 changed files
... ...
@@ -213,24 +213,7 @@ func (sr *swarmRouter) createService(ctx context.Context, w http.ResponseWriter,
213 213
 		if versions.LessThan(cliVersion, "1.30") {
214 214
 			queryRegistry = true
215 215
 		}
216
-		if versions.LessThan(cliVersion, "1.40") {
217
-			if service.TaskTemplate.ContainerSpec != nil {
218
-				// Sysctls for docker swarm services weren't supported before
219
-				// API version 1.40
220
-				service.TaskTemplate.ContainerSpec.Sysctls = nil
221
-
222
-				if service.TaskTemplate.ContainerSpec.Privileges != nil && service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec != nil {
223
-					// Support for setting credential-spec through configs was added in API 1.40
224
-					service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config = ""
225
-				}
226
-			}
227
-
228
-			if service.TaskTemplate.Placement != nil {
229
-				// MaxReplicas for docker swarm services weren't supported before
230
-				// API version 1.40
231
-				service.TaskTemplate.Placement.MaxReplicas = 0
232
-			}
233
-		}
216
+		adjustForAPIVersion(cliVersion, &service)
234 217
 	}
235 218
 
236 219
 	resp, err := sr.backend.CreateService(service, encodedAuth, queryRegistry)
... ...
@@ -270,24 +253,7 @@ func (sr *swarmRouter) updateService(ctx context.Context, w http.ResponseWriter,
270 270
 		if versions.LessThan(cliVersion, "1.30") {
271 271
 			queryRegistry = true
272 272
 		}
273
-		if versions.LessThan(cliVersion, "1.40") {
274
-			if service.TaskTemplate.ContainerSpec != nil {
275
-				// Sysctls for docker swarm services weren't supported before
276
-				// API version 1.40
277
-				service.TaskTemplate.ContainerSpec.Sysctls = nil
278
-
279
-				if service.TaskTemplate.ContainerSpec.Privileges != nil && service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec != nil {
280
-					// Support for setting credential-spec through configs was added in API 1.40
281
-					service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config = ""
282
-				}
283
-			}
284
-
285
-			if service.TaskTemplate.Placement != nil {
286
-				// MaxReplicas for docker swarm services weren't supported before
287
-				// API version 1.40
288
-				service.TaskTemplate.Placement.MaxReplicas = 0
289
-			}
290
-		}
273
+		adjustForAPIVersion(cliVersion, &service)
291 274
 	}
292 275
 
293 276
 	resp, err := sr.backend.UpdateService(vars["id"], version, service, flags, queryRegistry)
... ...
@@ -9,6 +9,8 @@ import (
9 9
 	"github.com/docker/docker/api/server/httputils"
10 10
 	basictypes "github.com/docker/docker/api/types"
11 11
 	"github.com/docker/docker/api/types/backend"
12
+	"github.com/docker/docker/api/types/swarm"
13
+	"github.com/docker/docker/api/types/versions"
12 14
 )
13 15
 
14 16
 // swarmLogs takes an http response, request, and selector, and writes the logs
... ...
@@ -64,3 +66,33 @@ func (sr *swarmRouter) swarmLogs(ctx context.Context, w io.Writer, r *http.Reque
64 64
 	httputils.WriteLogStream(ctx, w, msgs, logsConfig, !tty)
65 65
 	return nil
66 66
 }
67
+
68
+// adjustForAPIVersion takes a version and service spec and removes fields to
69
+// make the spec compatible with the specified version.
70
+func adjustForAPIVersion(cliVersion string, service *swarm.ServiceSpec) {
71
+	if cliVersion == "" {
72
+		return
73
+	}
74
+	if versions.LessThan(cliVersion, "1.40") {
75
+		if service.TaskTemplate.ContainerSpec != nil {
76
+			// Sysctls for docker swarm services weren't supported before
77
+			// API version 1.40
78
+			service.TaskTemplate.ContainerSpec.Sysctls = nil
79
+
80
+			if service.TaskTemplate.ContainerSpec.Privileges != nil && service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec != nil {
81
+				// Support for setting credential-spec through configs was added in API 1.40
82
+				service.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config = ""
83
+			}
84
+			for _, config := range service.TaskTemplate.ContainerSpec.Configs {
85
+				// support for the Runtime target was added in API 1.40
86
+				config.Runtime = nil
87
+			}
88
+		}
89
+
90
+		if service.TaskTemplate.Placement != nil {
91
+			// MaxReplicas for docker swarm services weren't supported before
92
+			// API version 1.40
93
+			service.TaskTemplate.Placement.MaxReplicas = 0
94
+		}
95
+	}
96
+}
67 97
new file mode 100644
... ...
@@ -0,0 +1,87 @@
0
+package swarm // import "github.com/docker/docker/api/server/router/swarm"
1
+
2
+import (
3
+	"reflect"
4
+	"testing"
5
+
6
+	"github.com/docker/docker/api/types/swarm"
7
+)
8
+
9
+func TestAdjustForAPIVersion(t *testing.T) {
10
+	var (
11
+		expectedSysctls = map[string]string{"foo": "bar"}
12
+	)
13
+	// testing the negative -- does this leave everything else alone? -- is
14
+	// prohibitively time-consuming to write, because it would need an object
15
+	// with literally every field filled in.
16
+	spec := &swarm.ServiceSpec{
17
+		TaskTemplate: swarm.TaskSpec{
18
+			ContainerSpec: &swarm.ContainerSpec{
19
+				Sysctls: expectedSysctls,
20
+				Privileges: &swarm.Privileges{
21
+					CredentialSpec: &swarm.CredentialSpec{
22
+						Config: "someconfig",
23
+					},
24
+				},
25
+				Configs: []*swarm.ConfigReference{
26
+					{
27
+						File: &swarm.ConfigReferenceFileTarget{
28
+							Name: "foo",
29
+							UID:  "bar",
30
+							GID:  "baz",
31
+						},
32
+						ConfigID:   "configFile",
33
+						ConfigName: "configFile",
34
+					},
35
+					{
36
+						Runtime:    &swarm.ConfigReferenceRuntimeTarget{},
37
+						ConfigID:   "configRuntime",
38
+						ConfigName: "configRuntime",
39
+					},
40
+				},
41
+			},
42
+			Placement: &swarm.Placement{
43
+				MaxReplicas: 222,
44
+			},
45
+		},
46
+	}
47
+
48
+	// first, does calling this with a later version correctly NOT strip
49
+	// fields? do the later version first, so we can reuse this spec in the
50
+	// next test.
51
+	adjustForAPIVersion("1.40", spec)
52
+	if !reflect.DeepEqual(spec.TaskTemplate.ContainerSpec.Sysctls, expectedSysctls) {
53
+		t.Error("Sysctls was stripped from spec")
54
+	}
55
+
56
+	if spec.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config != "someconfig" {
57
+		t.Error("CredentialSpec.Config field was stripped from spec")
58
+	}
59
+
60
+	if spec.TaskTemplate.ContainerSpec.Configs[1].Runtime == nil {
61
+		t.Error("ConfigReferenceRuntimeTarget was stripped from spec")
62
+	}
63
+
64
+	if spec.TaskTemplate.Placement.MaxReplicas != 222 {
65
+		t.Error("MaxReplicas was stripped from spec")
66
+	}
67
+
68
+	// next, does calling this with an earlier version correctly strip fields?
69
+	adjustForAPIVersion("1.29", spec)
70
+	if spec.TaskTemplate.ContainerSpec.Sysctls != nil {
71
+		t.Error("Sysctls was not stripped from spec")
72
+	}
73
+
74
+	if spec.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config != "" {
75
+		t.Error("CredentialSpec.Config field was not stripped from spec")
76
+	}
77
+
78
+	if spec.TaskTemplate.ContainerSpec.Configs[1].Runtime != nil {
79
+		t.Error("ConfigReferenceRuntimeTarget was not stripped from spec")
80
+	}
81
+
82
+	if spec.TaskTemplate.Placement.MaxReplicas != 0 {
83
+		t.Error("MaxReplicas was not stripped from spec")
84
+	}
85
+
86
+}
... ...
@@ -2628,6 +2628,7 @@ definitions:
2628 2628
                     example: "0bt9dmxjvjiqermk6xrop3ekq"
2629 2629
                     description: |
2630 2630
                       Load credential spec from a Swarm Config with the given ID.
2631
+                      The specified config must also be present in the Configs field with the Runtime property set.
2631 2632
 
2632 2633
                       <p><br /></p>
2633 2634
 
... ...
@@ -2768,7 +2769,12 @@ definitions:
2768 2768
               type: "object"
2769 2769
               properties:
2770 2770
                 File:
2771
-                  description: "File represents a specific target that is backed by a file."
2771
+                  description: |
2772
+                    File represents a specific target that is backed by a file.
2773
+
2774
+                    <p><br /><p>
2775
+
2776
+                    > **Note**: `Configs.File` and `Configs.Runtime` are mutually exclusive
2772 2777
                   type: "object"
2773 2778
                   properties:
2774 2779
                     Name:
... ...
@@ -2784,6 +2790,14 @@ definitions:
2784 2784
                       description: "Mode represents the FileMode of the file."
2785 2785
                       type: "integer"
2786 2786
                       format: "uint32"
2787
+                Runtime:
2788
+                  description: |
2789
+                    Runtime represents a target that is not mounted into the container but is used by the task
2790
+
2791
+                    <p><br /><p>
2792
+
2793
+                    > **Note**: `Configs.File` and `Configs.Runtime` are mutually exclusive
2794
+                  type: "object"
2787 2795
                 ConfigID:
2788 2796
                   description: "ConfigID represents the ID of the specific config that we're referencing."
2789 2797
                   type: "string"
... ...
@@ -27,9 +27,14 @@ type ConfigReferenceFileTarget struct {
27 27
 	Mode os.FileMode
28 28
 }
29 29
 
30
+// ConfigReferenceRuntimeTarget is a target for a config specifying that it
31
+// isn't mounted into the container but instead has some other purpose.
32
+type ConfigReferenceRuntimeTarget struct{}
33
+
30 34
 // ConfigReference is a reference to a config in swarm
31 35
 type ConfigReference struct {
32
-	File       *ConfigReferenceFileTarget
36
+	File       *ConfigReferenceFileTarget    `json:",omitempty"`
37
+	Runtime    *ConfigReferenceRuntimeTarget `json:",omitempty"`
33 38
 	ConfigID   string
34 39
 	ConfigName string
35 40
 }
... ...
@@ -178,14 +178,26 @@ func secretReferencesFromGRPC(sr []*swarmapi.SecretReference) []*types.SecretRef
178 178
 	return refs
179 179
 }
180 180
 
181
-func configReferencesToGRPC(sr []*types.ConfigReference) []*swarmapi.ConfigReference {
181
+func configReferencesToGRPC(sr []*types.ConfigReference) ([]*swarmapi.ConfigReference, error) {
182 182
 	refs := make([]*swarmapi.ConfigReference, 0, len(sr))
183 183
 	for _, s := range sr {
184 184
 		ref := &swarmapi.ConfigReference{
185 185
 			ConfigID:   s.ConfigID,
186 186
 			ConfigName: s.ConfigName,
187 187
 		}
188
-		if s.File != nil {
188
+		switch {
189
+		case s.Runtime == nil && s.File == nil:
190
+			return nil, errors.New("either File or Runtime should be set")
191
+		case s.Runtime != nil && s.File != nil:
192
+			return nil, errors.New("cannot specify both File and Runtime")
193
+		case s.Runtime != nil:
194
+			// Runtime target was added in API v1.40 and takes precedence over
195
+			// File target. However, File and Runtime targets are mutually exclusive,
196
+			// so we should never have both.
197
+			ref.Target = &swarmapi.ConfigReference_Runtime{
198
+				Runtime: &swarmapi.RuntimeTarget{},
199
+			}
200
+		case s.File != nil:
189 201
 			ref.Target = &swarmapi.ConfigReference_File{
190 202
 				File: &swarmapi.FileTarget{
191 203
 					Name: s.File.Name,
... ...
@@ -199,28 +211,32 @@ func configReferencesToGRPC(sr []*types.ConfigReference) []*swarmapi.ConfigRefer
199 199
 		refs = append(refs, ref)
200 200
 	}
201 201
 
202
-	return refs
202
+	return refs, nil
203 203
 }
204 204
 
205 205
 func configReferencesFromGRPC(sr []*swarmapi.ConfigReference) []*types.ConfigReference {
206 206
 	refs := make([]*types.ConfigReference, 0, len(sr))
207 207
 	for _, s := range sr {
208
-		target := s.GetFile()
209
-		if target == nil {
210
-			// not a file target
211
-			logrus.Warnf("config target not a file: config=%s", s.ConfigID)
212
-			continue
208
+
209
+		r := &types.ConfigReference{
210
+			ConfigID:   s.ConfigID,
211
+			ConfigName: s.ConfigName,
213 212
 		}
214
-		refs = append(refs, &types.ConfigReference{
215
-			File: &types.ConfigReferenceFileTarget{
213
+		if target := s.GetRuntime(); target != nil {
214
+			r.Runtime = &types.ConfigReferenceRuntimeTarget{}
215
+		} else if target := s.GetFile(); target != nil {
216
+			r.File = &types.ConfigReferenceFileTarget{
216 217
 				Name: target.Name,
217 218
 				UID:  target.UID,
218 219
 				GID:  target.GID,
219 220
 				Mode: target.Mode,
220
-			},
221
-			ConfigID:   s.ConfigID,
222
-			ConfigName: s.ConfigName,
223
-		})
221
+			}
222
+		} else {
223
+			// not a file target
224
+			logrus.Warnf("config target not known: config=%s", s.ConfigID)
225
+			continue
226
+		}
227
+		refs = append(refs, r)
224 228
 	}
225 229
 
226 230
 	return refs
... ...
@@ -243,7 +259,6 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) {
243 243
 		ReadOnly:   c.ReadOnly,
244 244
 		Hosts:      c.Hosts,
245 245
 		Secrets:    secretReferencesToGRPC(c.Secrets),
246
-		Configs:    configReferencesToGRPC(c.Configs),
247 246
 		Isolation:  isolationToGRPC(c.Isolation),
248 247
 		Init:       initToGRPC(c.Init),
249 248
 		Sysctls:    c.Sysctls,
... ...
@@ -284,6 +299,14 @@ func containerToGRPC(c *types.ContainerSpec) (*swarmapi.ContainerSpec, error) {
284 284
 		}
285 285
 	}
286 286
 
287
+	if c.Configs != nil {
288
+		configs, err := configReferencesToGRPC(c.Configs)
289
+		if err != nil {
290
+			return nil, errors.Wrap(err, "invalid Config")
291
+		}
292
+		containerSpec.Configs = configs
293
+	}
294
+
287 295
 	// Mounts
288 296
 	for _, m := range c.Mounts {
289 297
 		mount := swarmapi.Mount{
... ...
@@ -467,3 +467,147 @@ func TestTaskConvertFromGRPCNetworkAttachment(t *testing.T) {
467 467
 		t.Fatalf("expected Runtime to be %v", swarmtypes.RuntimeNetworkAttachment)
468 468
 	}
469 469
 }
470
+
471
+// TestServiceConvertFromGRPCConfigs tests that converting config references
472
+// from GRPC is correct
473
+func TestServiceConvertFromGRPCConfigs(t *testing.T) {
474
+	cases := []struct {
475
+		name string
476
+		from *swarmapi.ConfigReference
477
+		to   *swarmtypes.ConfigReference
478
+	}{
479
+		{
480
+			name: "file",
481
+			from: &swarmapi.ConfigReference{
482
+				ConfigID:   "configFile",
483
+				ConfigName: "configFile",
484
+				Target: &swarmapi.ConfigReference_File{
485
+					// skip mode, if everything else here works mode will too. otherwise we'd need to import os.
486
+					File: &swarmapi.FileTarget{Name: "foo", UID: "bar", GID: "baz"},
487
+				},
488
+			},
489
+			to: &swarmtypes.ConfigReference{
490
+				ConfigID:   "configFile",
491
+				ConfigName: "configFile",
492
+				File:       &swarmtypes.ConfigReferenceFileTarget{Name: "foo", UID: "bar", GID: "baz"},
493
+			},
494
+		},
495
+		{
496
+			name: "runtime",
497
+			from: &swarmapi.ConfigReference{
498
+				ConfigID:   "configRuntime",
499
+				ConfigName: "configRuntime",
500
+				Target:     &swarmapi.ConfigReference_Runtime{Runtime: &swarmapi.RuntimeTarget{}},
501
+			},
502
+			to: &swarmtypes.ConfigReference{
503
+				ConfigID:   "configRuntime",
504
+				ConfigName: "configRuntime",
505
+				Runtime:    &swarmtypes.ConfigReferenceRuntimeTarget{},
506
+			},
507
+		},
508
+	}
509
+
510
+	for _, tc := range cases {
511
+		t.Run(tc.name, func(t *testing.T) {
512
+			grpcService := swarmapi.Service{
513
+				Spec: swarmapi.ServiceSpec{
514
+					Task: swarmapi.TaskSpec{
515
+						Runtime: &swarmapi.TaskSpec_Container{
516
+							Container: &swarmapi.ContainerSpec{
517
+								Configs: []*swarmapi.ConfigReference{tc.from},
518
+							},
519
+						},
520
+					},
521
+				},
522
+			}
523
+
524
+			engineService, err := ServiceFromGRPC(grpcService)
525
+			assert.NilError(t, err)
526
+			assert.DeepEqual(t,
527
+				engineService.Spec.TaskTemplate.ContainerSpec.Configs[0],
528
+				tc.to,
529
+			)
530
+		})
531
+	}
532
+}
533
+
534
+// TestServiceConvertToGRPCConfigs tests that converting config references to
535
+// GRPC is correct
536
+func TestServiceConvertToGRPCConfigs(t *testing.T) {
537
+	cases := []struct {
538
+		name        string
539
+		from        *swarmtypes.ConfigReference
540
+		to          *swarmapi.ConfigReference
541
+		expectedErr string
542
+	}{
543
+		{
544
+			name: "file",
545
+			from: &swarmtypes.ConfigReference{
546
+				ConfigID:   "configFile",
547
+				ConfigName: "configFile",
548
+				File:       &swarmtypes.ConfigReferenceFileTarget{Name: "foo", UID: "bar", GID: "baz"},
549
+			},
550
+			to: &swarmapi.ConfigReference{
551
+				ConfigID:   "configFile",
552
+				ConfigName: "configFile",
553
+				Target: &swarmapi.ConfigReference_File{
554
+					// skip mode, if everything else here works mode will too. otherwise we'd need to import os.
555
+					File: &swarmapi.FileTarget{Name: "foo", UID: "bar", GID: "baz"},
556
+				},
557
+			},
558
+		},
559
+		{
560
+			name: "runtime",
561
+			from: &swarmtypes.ConfigReference{
562
+				ConfigID:   "configRuntime",
563
+				ConfigName: "configRuntime",
564
+				Runtime:    &swarmtypes.ConfigReferenceRuntimeTarget{},
565
+			},
566
+			to: &swarmapi.ConfigReference{
567
+				ConfigID:   "configRuntime",
568
+				ConfigName: "configRuntime",
569
+				Target:     &swarmapi.ConfigReference_Runtime{Runtime: &swarmapi.RuntimeTarget{}},
570
+			},
571
+		},
572
+		{
573
+			name: "file and runtime",
574
+			from: &swarmtypes.ConfigReference{
575
+				ConfigID:   "fileAndRuntime",
576
+				ConfigName: "fileAndRuntime",
577
+				File:       &swarmtypes.ConfigReferenceFileTarget{},
578
+				Runtime:    &swarmtypes.ConfigReferenceRuntimeTarget{},
579
+			},
580
+			expectedErr: "invalid Config: cannot specify both File and Runtime",
581
+		},
582
+		{
583
+			name: "none",
584
+			from: &swarmtypes.ConfigReference{
585
+				ConfigID:   "none",
586
+				ConfigName: "none",
587
+			},
588
+			expectedErr: "invalid Config: either File or Runtime should be set",
589
+		},
590
+	}
591
+
592
+	for _, tc := range cases {
593
+		t.Run(tc.name, func(t *testing.T) {
594
+			engineServiceSpec := swarmtypes.ServiceSpec{
595
+				TaskTemplate: swarmtypes.TaskSpec{
596
+					ContainerSpec: &swarmtypes.ContainerSpec{
597
+						Configs: []*swarmtypes.ConfigReference{tc.from},
598
+					},
599
+				},
600
+			}
601
+
602
+			grpcServiceSpec, err := ServiceSpecToGRPC(engineServiceSpec)
603
+			if tc.expectedErr != "" {
604
+				assert.Error(t, err, tc.expectedErr)
605
+				return
606
+			}
607
+
608
+			assert.NilError(t, err)
609
+			taskRuntime := grpcServiceSpec.Task.Runtime.(*swarmapi.TaskSpec_Container)
610
+			assert.DeepEqual(t, taskRuntime.Container.Configs[0], tc.to)
611
+		})
612
+	}
613
+}
... ...
@@ -230,7 +230,14 @@ func (daemon *Daemon) setupSecretDir(c *container.Container) (setupErr error) {
230 230
 	for _, ref := range c.ConfigReferences {
231 231
 		// TODO (ehazlett): use type switch when more are supported
232 232
 		if ref.File == nil {
233
-			logrus.Error("config target type is not a file target")
233
+			// Runtime configs are not mounted into the container, but they're
234
+			// a valid type of config so we should not error when we encounter
235
+			// one.
236
+			if ref.Runtime == nil {
237
+				logrus.Error("config target type is not a file or runtime target")
238
+			}
239
+			// However, in any case, this isn't a file config, so we have no
240
+			// further work to do
234 241
 			continue
235 242
 		}
236 243
 
... ...
@@ -44,7 +44,14 @@ func (daemon *Daemon) setupConfigDir(c *container.Container) (setupErr error) {
44 44
 	for _, configRef := range c.ConfigReferences {
45 45
 		// TODO (ehazlett): use type switch when more are supported
46 46
 		if configRef.File == nil {
47
-			logrus.Error("config target type is not a file target")
47
+			// Runtime configs are not mounted into the container, but they're
48
+			// a valid type of config so we should not error when we encounter
49
+			// one.
50
+			if configRef.Runtime == nil {
51
+				logrus.Error("config target type is not a file or runtime target")
52
+			}
53
+			// However, in any case, this isn't a file config, so we have no
54
+			// further work to do
48 55
 			continue
49 56
 		}
50 57
 
... ...
@@ -289,18 +289,23 @@ func (daemon *Daemon) createSpecWindowsFields(c *container.Container, s *specs.S
289 289
 					return err
290 290
 				}
291 291
 			} else if match, csValue = getCredentialSpec("config://", splitsOpt[1]); match {
292
+				// if the container does not have a DependencyStore, then it
293
+				// isn't swarmkit managed. In order to avoid creating any
294
+				// impression that `config://` is a valid API, return the same
295
+				// error as if you'd passed any other random word.
296
+				if c.DependencyStore == nil {
297
+					return fmt.Errorf("invalid credential spec security option - value must be prefixed file:// or registry:// followed by a value")
298
+				}
299
+
300
+				// after this point, we can return regular swarmkit-relevant
301
+				// errors, because we'll know this container is managed.
292 302
 				if csValue == "" {
293 303
 					return fmt.Errorf("no value supplied for config:// credential spec security option")
294 304
 				}
295 305
 
296
-				// if the container does not have a DependencyStore, then we
297
-				// return an error
298
-				if c.DependencyStore == nil {
299
-					return fmt.Errorf("cannot use config:// credential spec security option if not swarmkit managed")
300
-				}
301 306
 				csConfig, err := c.DependencyStore.Configs().Get(csValue)
302 307
 				if err != nil {
303
-					return fmt.Errorf("error getting value from config store: %v", err)
308
+					return errors.Wrap(err, "error getting value from config store")
304 309
 				}
305 310
 				// stuff the resulting secret data into a string to use as the
306 311
 				// CredentialSpec
... ...
@@ -31,6 +31,8 @@ keywords: "API, Docker, rcli, REST, documentation"
31 31
 * `POST /services/{id}/update` now accepts `Sysctls` as part of the `ContainerSpec`.
32 32
 * `POST /services/create` now accepts `Config` as part of `ContainerSpec.Privileges.CredentialSpec`.
33 33
 * `POST /services/{id}/update` now accepts `Config` as part of `ContainerSpec.Privileges.CredentialSpec`.
34
+* `POST /services/create` now includes `Runtime` as an option in `ContainerSpec.Configs`
35
+* `POST /services/{id}/update` now includes `Runtime` as an option in `ContainerSpec.Configs`
34 36
 * `GET /tasks` now  returns `Sysctls` as part of the `ContainerSpec`.
35 37
 * `GET /tasks/{id}` now  returns `Sysctls` as part of the `ContainerSpec`.
36 38
 * `GET /nodes` now supports a filter type `node.label` filter to filter nodes based