Browse code

Merge pull request #30781 from AkihiroSuda/fix-stack-env

compose: fix environment interpolation from the client

Vincent Demeester authored on 2017/03/17 23:56:50
Showing 9 changed files
... ...
@@ -15,6 +15,7 @@ import (
15 15
 	composetypes "github.com/docker/docker/cli/compose/types"
16 16
 	apiclient "github.com/docker/docker/client"
17 17
 	dockerclient "github.com/docker/docker/client"
18
+	"github.com/pkg/errors"
18 19
 	"golang.org/x/net/context"
19 20
 )
20 21
 
... ...
@@ -122,9 +123,26 @@ func getConfigDetails(opts deployOptions) (composetypes.ConfigDetails, error) {
122 122
 	}
123 123
 	// TODO: support multiple files
124 124
 	details.ConfigFiles = []composetypes.ConfigFile{*configFile}
125
+	details.Environment, err = buildEnvironment(os.Environ())
126
+	if err != nil {
127
+		return details, err
128
+	}
125 129
 	return details, nil
126 130
 }
127 131
 
132
+func buildEnvironment(env []string) (map[string]string, error) {
133
+	result := make(map[string]string, len(env))
134
+	for _, s := range env {
135
+		// if value is empty, s is like "K=", not "K".
136
+		if !strings.Contains(s, "=") {
137
+			return result, errors.Errorf("unexpected environment %q", s)
138
+		}
139
+		kv := strings.SplitN(s, "=", 2)
140
+		result[kv[0]] = kv[1]
141
+	}
142
+	return result, nil
143
+}
144
+
128 145
 func getConfigFile(filename string) (*composetypes.ConfigFile, error) {
129 146
 	bytes, err := ioutil.ReadFile(filename)
130 147
 	if err != nil {
... ...
@@ -394,11 +394,16 @@ func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortC
394 394
 	}, nil
395 395
 }
396 396
 
397
-func convertEnvironment(source map[string]string) []string {
397
+func convertEnvironment(source map[string]*string) []string {
398 398
 	var output []string
399 399
 
400 400
 	for name, value := range source {
401
-		output = append(output, fmt.Sprintf("%s=%s", name, value))
401
+		switch value {
402
+		case nil:
403
+			output = append(output, name)
404
+		default:
405
+			output = append(output, fmt.Sprintf("%s=%s", name, *value))
406
+		}
402 407
 	}
403 408
 
404 409
 	return output
... ...
@@ -43,10 +43,14 @@ func TestConvertRestartPolicyFromFailure(t *testing.T) {
43 43
 	assert.DeepEqual(t, policy, expected)
44 44
 }
45 45
 
46
+func strPtr(val string) *string {
47
+	return &val
48
+}
49
+
46 50
 func TestConvertEnvironment(t *testing.T) {
47
-	source := map[string]string{
48
-		"foo": "bar",
49
-		"key": "value",
51
+	source := map[string]*string{
52
+		"foo": strPtr("bar"),
53
+		"key": strPtr("value"),
50 54
 	}
51 55
 	env := convertEnvironment(source)
52 56
 	sort.Strings(env)
... ...
@@ -1,8 +1,8 @@
1 1
 # passed through
2
-FOO=1
2
+FOO=foo_from_env_file
3 3
 
4 4
 # overridden in example2.env
5
-BAR=1
5
+BAR=bar_from_env_file
6 6
 
7 7
 # overridden in full-example.yml
8
-BAZ=1
8
+BAZ=baz_from_env_file
... ...
@@ -1 +1,4 @@
1
-BAR=2
1
+BAR=bar_from_env_file_2
2
+
3
+# overridden in configDetails.Environment
4
+QUX=quz_from_env_file_2
... ...
@@ -77,10 +77,8 @@ services:
77 77
     # Mapping values can be strings, numbers or null
78 78
     # Booleans are not allowed - must be quoted
79 79
     environment:
80
-      RACK_ENV: development
81
-      SHOW: 'true'
82
-      SESSION_SECRET:
83
-      BAZ: 3
80
+      BAZ: baz_from_service_def
81
+      QUX:
84 82
     # environment:
85 83
     #   - RACK_ENV=development
86 84
     #   - SHOW=true
... ...
@@ -2,15 +2,16 @@ package loader
2 2
 
3 3
 import (
4 4
 	"fmt"
5
-	"os"
6 5
 	"path"
7 6
 	"reflect"
8 7
 	"regexp"
9 8
 	"sort"
10 9
 	"strings"
11 10
 
11
+	"github.com/Sirupsen/logrus"
12 12
 	"github.com/docker/docker/cli/compose/interpolation"
13 13
 	"github.com/docker/docker/cli/compose/schema"
14
+	"github.com/docker/docker/cli/compose/template"
14 15
 	"github.com/docker/docker/cli/compose/types"
15 16
 	"github.com/docker/docker/opts"
16 17
 	runconfigopts "github.com/docker/docker/runconfig/opts"
... ...
@@ -69,13 +70,17 @@ func Load(configDetails types.ConfigDetails) (*types.Config, error) {
69 69
 	}
70 70
 
71 71
 	cfg := types.Config{}
72
+	lookupEnv := func(k string) (string, bool) {
73
+		v, ok := configDetails.Environment[k]
74
+		return v, ok
75
+	}
72 76
 	if services, ok := configDict["services"]; ok {
73
-		servicesConfig, err := interpolation.Interpolate(services.(types.Dict), "service", os.LookupEnv)
77
+		servicesConfig, err := interpolation.Interpolate(services.(types.Dict), "service", lookupEnv)
74 78
 		if err != nil {
75 79
 			return nil, err
76 80
 		}
77 81
 
78
-		servicesList, err := LoadServices(servicesConfig, configDetails.WorkingDir)
82
+		servicesList, err := LoadServices(servicesConfig, configDetails.WorkingDir, lookupEnv)
79 83
 		if err != nil {
80 84
 			return nil, err
81 85
 		}
... ...
@@ -84,7 +89,7 @@ func Load(configDetails types.ConfigDetails) (*types.Config, error) {
84 84
 	}
85 85
 
86 86
 	if networks, ok := configDict["networks"]; ok {
87
-		networksConfig, err := interpolation.Interpolate(networks.(types.Dict), "network", os.LookupEnv)
87
+		networksConfig, err := interpolation.Interpolate(networks.(types.Dict), "network", lookupEnv)
88 88
 		if err != nil {
89 89
 			return nil, err
90 90
 		}
... ...
@@ -98,7 +103,7 @@ func Load(configDetails types.ConfigDetails) (*types.Config, error) {
98 98
 	}
99 99
 
100 100
 	if volumes, ok := configDict["volumes"]; ok {
101
-		volumesConfig, err := interpolation.Interpolate(volumes.(types.Dict), "volume", os.LookupEnv)
101
+		volumesConfig, err := interpolation.Interpolate(volumes.(types.Dict), "volume", lookupEnv)
102 102
 		if err != nil {
103 103
 			return nil, err
104 104
 		}
... ...
@@ -112,7 +117,7 @@ func Load(configDetails types.ConfigDetails) (*types.Config, error) {
112 112
 	}
113 113
 
114 114
 	if secrets, ok := configDict["secrets"]; ok {
115
-		secretsConfig, err := interpolation.Interpolate(secrets.(types.Dict), "secret", os.LookupEnv)
115
+		secretsConfig, err := interpolation.Interpolate(secrets.(types.Dict), "secret", lookupEnv)
116 116
 		if err != nil {
117 117
 			return nil, err
118 118
 		}
... ...
@@ -248,9 +253,11 @@ func transformHook(
248 248
 	case reflect.TypeOf(map[string]*types.ServiceNetworkConfig{}):
249 249
 		return transformServiceNetworkMap(data)
250 250
 	case reflect.TypeOf(types.MappingWithEquals{}):
251
-		return transformMappingOrList(data, "="), nil
251
+		return transformMappingOrList(data, "=", true), nil
252
+	case reflect.TypeOf(types.Labels{}):
253
+		return transformMappingOrList(data, "=", false), nil
252 254
 	case reflect.TypeOf(types.MappingWithColon{}):
253
-		return transformMappingOrList(data, ":"), nil
255
+		return transformMappingOrList(data, ":", false), nil
254 256
 	case reflect.TypeOf(types.ServiceVolumeConfig{}):
255 257
 		return transformServiceVolumeConfig(data)
256 258
 	}
... ...
@@ -308,11 +315,11 @@ func formatInvalidKeyError(keyPrefix string, key interface{}) error {
308 308
 
309 309
 // LoadServices produces a ServiceConfig map from a compose file Dict
310 310
 // the servicesDict is not validated if directly used. Use Load() to enable validation
311
-func LoadServices(servicesDict types.Dict, workingDir string) ([]types.ServiceConfig, error) {
311
+func LoadServices(servicesDict types.Dict, workingDir string, lookupEnv template.Mapping) ([]types.ServiceConfig, error) {
312 312
 	var services []types.ServiceConfig
313 313
 
314 314
 	for name, serviceDef := range servicesDict {
315
-		serviceConfig, err := LoadService(name, serviceDef.(types.Dict), workingDir)
315
+		serviceConfig, err := LoadService(name, serviceDef.(types.Dict), workingDir, lookupEnv)
316 316
 		if err != nil {
317 317
 			return nil, err
318 318
 		}
... ...
@@ -324,23 +331,35 @@ func LoadServices(servicesDict types.Dict, workingDir string) ([]types.ServiceCo
324 324
 
325 325
 // LoadService produces a single ServiceConfig from a compose file Dict
326 326
 // the serviceDict is not validated if directly used. Use Load() to enable validation
327
-func LoadService(name string, serviceDict types.Dict, workingDir string) (*types.ServiceConfig, error) {
327
+func LoadService(name string, serviceDict types.Dict, workingDir string, lookupEnv template.Mapping) (*types.ServiceConfig, error) {
328 328
 	serviceConfig := &types.ServiceConfig{}
329 329
 	if err := transform(serviceDict, serviceConfig); err != nil {
330 330
 		return nil, err
331 331
 	}
332 332
 	serviceConfig.Name = name
333 333
 
334
-	if err := resolveEnvironment(serviceConfig, workingDir); err != nil {
334
+	if err := resolveEnvironment(serviceConfig, workingDir, lookupEnv); err != nil {
335 335
 		return nil, err
336 336
 	}
337 337
 
338
-	resolveVolumePaths(serviceConfig.Volumes, workingDir)
338
+	resolveVolumePaths(serviceConfig.Volumes, workingDir, lookupEnv)
339 339
 	return serviceConfig, nil
340 340
 }
341 341
 
342
-func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string) error {
343
-	environment := make(map[string]string)
342
+func updateEnvironment(environment map[string]*string, vars map[string]*string, lookupEnv template.Mapping) {
343
+	for k, v := range vars {
344
+		interpolatedV, ok := lookupEnv(k)
345
+		if (v == nil || *v == "") && ok {
346
+			// lookupEnv is prioritized over vars
347
+			environment[k] = &interpolatedV
348
+		} else {
349
+			environment[k] = v
350
+		}
351
+	}
352
+}
353
+
354
+func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string, lookupEnv template.Mapping) error {
355
+	environment := make(map[string]*string)
344 356
 
345 357
 	if len(serviceConfig.EnvFile) > 0 {
346 358
 		var envVars []string
... ...
@@ -353,36 +372,35 @@ func resolveEnvironment(serviceConfig *types.ServiceConfig, workingDir string) e
353 353
 			}
354 354
 			envVars = append(envVars, fileVars...)
355 355
 		}
356
-
357
-		for k, v := range runconfigopts.ConvertKVStringsToMap(envVars) {
358
-			environment[k] = v
359
-		}
360
-	}
361
-
362
-	for k, v := range serviceConfig.Environment {
363
-		environment[k] = v
356
+		updateEnvironment(environment,
357
+			runconfigopts.ConvertKVStringsToMapWithNil(envVars), lookupEnv)
364 358
 	}
365 359
 
360
+	updateEnvironment(environment, serviceConfig.Environment, lookupEnv)
366 361
 	serviceConfig.Environment = environment
367
-
368 362
 	return nil
369 363
 }
370 364
 
371
-func resolveVolumePaths(volumes []types.ServiceVolumeConfig, workingDir string) {
365
+func resolveVolumePaths(volumes []types.ServiceVolumeConfig, workingDir string, lookupEnv template.Mapping) {
372 366
 	for i, volume := range volumes {
373 367
 		if volume.Type != "bind" {
374 368
 			continue
375 369
 		}
376 370
 
377
-		volume.Source = absPath(workingDir, expandUser(volume.Source))
371
+		volume.Source = absPath(workingDir, expandUser(volume.Source, lookupEnv))
378 372
 		volumes[i] = volume
379 373
 	}
380 374
 }
381 375
 
382 376
 // TODO: make this more robust
383
-func expandUser(path string) string {
377
+func expandUser(path string, lookupEnv template.Mapping) string {
384 378
 	if strings.HasPrefix(path, "~") {
385
-		return strings.Replace(path, "~", os.Getenv("HOME"), 1)
379
+		home, ok := lookupEnv("HOME")
380
+		if !ok {
381
+			logrus.Warn("cannot expand '~', because the environment lacks HOME")
382
+			return path
383
+		}
384
+		return strings.Replace(path, "~", home, 1)
386 385
 	}
387 386
 	return path
388 387
 }
... ...
@@ -476,9 +494,9 @@ func absPath(workingDir string, filepath string) string {
476 476
 func transformMapStringString(data interface{}) (interface{}, error) {
477 477
 	switch value := data.(type) {
478 478
 	case map[string]interface{}:
479
-		return toMapStringString(value), nil
479
+		return toMapStringString(value, false), nil
480 480
 	case types.Dict:
481
-		return toMapStringString(value), nil
481
+		return toMapStringString(value, false), nil
482 482
 	case map[string]string:
483 483
 		return value, nil
484 484
 	default:
... ...
@@ -592,23 +610,27 @@ func transformStringList(data interface{}) (interface{}, error) {
592 592
 	}
593 593
 }
594 594
 
595
-func transformMappingOrList(mappingOrList interface{}, sep string) map[string]string {
596
-	if mapping, ok := mappingOrList.(types.Dict); ok {
597
-		return toMapStringString(mapping)
598
-	}
599
-	if list, ok := mappingOrList.([]interface{}); ok {
600
-		result := make(map[string]string)
601
-		for _, value := range list {
595
+func transformMappingOrList(mappingOrList interface{}, sep string, allowNil bool) interface{} {
596
+	switch value := mappingOrList.(type) {
597
+	case types.Dict:
598
+		return toMapStringString(value, allowNil)
599
+	case ([]interface{}):
600
+		result := make(map[string]interface{})
601
+		for _, value := range value {
602 602
 			parts := strings.SplitN(value.(string), sep, 2)
603
-			if len(parts) == 1 {
604
-				result[parts[0]] = ""
605
-			} else {
606
-				result[parts[0]] = parts[1]
603
+			key := parts[0]
604
+			switch {
605
+			case len(parts) == 1 && allowNil:
606
+				result[key] = nil
607
+			case len(parts) == 1 && !allowNil:
608
+				result[key] = ""
609
+			default:
610
+				result[key] = parts[1]
607 611
 			}
608 612
 		}
609 613
 		return result
610 614
 	}
611
-	panic(fmt.Errorf("expected a map or a slice, got: %#v", mappingOrList))
615
+	panic(fmt.Errorf("expected a map or a list, got %T: %#v", mappingOrList, mappingOrList))
612 616
 }
613 617
 
614 618
 func transformShellCommand(value interface{}) (interface{}, error) {
... ...
@@ -672,17 +694,21 @@ func toServicePortConfigs(value string) ([]interface{}, error) {
672 672
 	return portConfigs, nil
673 673
 }
674 674
 
675
-func toMapStringString(value map[string]interface{}) map[string]string {
676
-	output := make(map[string]string)
675
+func toMapStringString(value map[string]interface{}, allowNil bool) map[string]interface{} {
676
+	output := make(map[string]interface{})
677 677
 	for key, value := range value {
678
-		output[key] = toString(value)
678
+		output[key] = toString(value, allowNil)
679 679
 	}
680 680
 	return output
681 681
 }
682 682
 
683
-func toString(value interface{}) string {
684
-	if value == nil {
683
+func toString(value interface{}, allowNil bool) interface{} {
684
+	switch {
685
+	case value != nil:
686
+		return fmt.Sprint(value)
687
+	case allowNil:
688
+		return nil
689
+	default:
685 690
 		return ""
686 691
 	}
687
-	return fmt.Sprint(value)
688 692
 }
... ...
@@ -12,7 +12,7 @@ import (
12 12
 	"github.com/stretchr/testify/assert"
13 13
 )
14 14
 
15
-func buildConfigDetails(source types.Dict) types.ConfigDetails {
15
+func buildConfigDetails(source types.Dict, env map[string]string) types.ConfigDetails {
16 16
 	workingDir, err := os.Getwd()
17 17
 	if err != nil {
18 18
 		panic(err)
... ...
@@ -23,10 +23,23 @@ func buildConfigDetails(source types.Dict) types.ConfigDetails {
23 23
 		ConfigFiles: []types.ConfigFile{
24 24
 			{Filename: "filename.yml", Config: source},
25 25
 		},
26
-		Environment: nil,
26
+		Environment: env,
27 27
 	}
28 28
 }
29 29
 
30
+func loadYAML(yaml string) (*types.Config, error) {
31
+	return loadYAMLWithEnv(yaml, nil)
32
+}
33
+
34
+func loadYAMLWithEnv(yaml string, env map[string]string) (*types.Config, error) {
35
+	dict, err := ParseYAML([]byte(yaml))
36
+	if err != nil {
37
+		return nil, err
38
+	}
39
+
40
+	return Load(buildConfigDetails(dict, env))
41
+}
42
+
30 43
 var sampleYAML = `
31 44
 version: "3"
32 45
 services:
... ...
@@ -98,12 +111,16 @@ var sampleDict = types.Dict{
98 98
 	},
99 99
 }
100 100
 
101
+func strPtr(val string) *string {
102
+	return &val
103
+}
104
+
101 105
 var sampleConfig = types.Config{
102 106
 	Services: []types.ServiceConfig{
103 107
 		{
104 108
 			Name:        "foo",
105 109
 			Image:       "busybox",
106
-			Environment: map[string]string{},
110
+			Environment: map[string]*string{},
107 111
 			Networks: map[string]*types.ServiceNetworkConfig{
108 112
 				"with_me": nil,
109 113
 			},
... ...
@@ -111,7 +128,7 @@ var sampleConfig = types.Config{
111 111
 		{
112 112
 			Name:        "bar",
113 113
 			Image:       "busybox",
114
-			Environment: map[string]string{"FOO": "1"},
114
+			Environment: map[string]*string{"FOO": strPtr("1")},
115 115
 			Networks: map[string]*types.ServiceNetworkConfig{
116 116
 				"with_ipam": nil,
117 117
 			},
... ...
@@ -154,7 +171,7 @@ func TestParseYAML(t *testing.T) {
154 154
 }
155 155
 
156 156
 func TestLoad(t *testing.T) {
157
-	actual, err := Load(buildConfigDetails(sampleDict))
157
+	actual, err := Load(buildConfigDetails(sampleDict, nil))
158 158
 	if !assert.NoError(t, err) {
159 159
 		return
160 160
 	}
... ...
@@ -373,8 +390,8 @@ services:
373 373
 	assert.Contains(t, err.Error(), "services.foo.image must be a string")
374 374
 }
375 375
 
376
-func TestValidEnvironment(t *testing.T) {
377
-	config, err := loadYAML(`
376
+func TestLoadWithEnvironment(t *testing.T) {
377
+	config, err := loadYAMLWithEnv(`
378 378
 version: "3"
379 379
 services:
380 380
   dict-env:
... ...
@@ -383,6 +400,7 @@ services:
383 383
       FOO: "1"
384 384
       BAR: 2
385 385
       BAZ: 2.5
386
+      QUX:
386 387
       QUUX:
387 388
   list-env:
388 389
     image: busybox
... ...
@@ -390,15 +408,17 @@ services:
390 390
       - FOO=1
391 391
       - BAR=2
392 392
       - BAZ=2.5
393
-      - QUUX=
394
-`)
393
+      - QUX=
394
+      - QUUX
395
+`, map[string]string{"QUX": "qux"})
395 396
 	assert.NoError(t, err)
396 397
 
397 398
 	expected := types.MappingWithEquals{
398
-		"FOO":  "1",
399
-		"BAR":  "2",
400
-		"BAZ":  "2.5",
401
-		"QUUX": "",
399
+		"FOO":  strPtr("1"),
400
+		"BAR":  strPtr("2"),
401
+		"BAZ":  strPtr("2.5"),
402
+		"QUX":  strPtr("qux"),
403
+		"QUUX": nil,
402 404
 	}
403 405
 
404 406
 	assert.Equal(t, 2, len(config.Services))
... ...
@@ -434,7 +454,8 @@ services:
434 434
 }
435 435
 
436 436
 func TestEnvironmentInterpolation(t *testing.T) {
437
-	config, err := loadYAML(`
437
+	home := "/home/foo"
438
+	config, err := loadYAMLWithEnv(`
438 439
 version: "3"
439 440
 services:
440 441
   test:
... ...
@@ -450,13 +471,14 @@ networks:
450 450
 volumes:
451 451
   test:
452 452
     driver: $HOME
453
-`)
453
+`, map[string]string{
454
+		"HOME": home,
455
+		"FOO":  "foo",
456
+	})
454 457
 
455 458
 	assert.NoError(t, err)
456 459
 
457
-	home := os.Getenv("HOME")
458
-
459
-	expectedLabels := types.MappingWithEquals{
460
+	expectedLabels := types.Labels{
460 461
 		"home1":       home,
461 462
 		"home2":       home,
462 463
 		"nonexistent": "",
... ...
@@ -483,7 +505,7 @@ services:
483 483
 `))
484 484
 	assert.NoError(t, err)
485 485
 
486
-	configDetails := buildConfigDetails(dict)
486
+	configDetails := buildConfigDetails(dict, nil)
487 487
 
488 488
 	_, err = Load(configDetails)
489 489
 	assert.NoError(t, err)
... ...
@@ -506,7 +528,7 @@ services:
506 506
 `))
507 507
 	assert.NoError(t, err)
508 508
 
509
-	configDetails := buildConfigDetails(dict)
509
+	configDetails := buildConfigDetails(dict, nil)
510 510
 
511 511
 	_, err = Load(configDetails)
512 512
 	assert.NoError(t, err)
... ...
@@ -601,7 +623,9 @@ func TestFullExample(t *testing.T) {
601 601
 	bytes, err := ioutil.ReadFile("full-example.yml")
602 602
 	assert.NoError(t, err)
603 603
 
604
-	config, err := loadYAML(string(bytes))
604
+	homeDir := "/home/foo"
605
+	env := map[string]string{"HOME": homeDir, "QUX": "qux_from_environment"}
606
+	config, err := loadYAMLWithEnv(string(bytes), env)
605 607
 	if !assert.NoError(t, err) {
606 608
 		return
607 609
 	}
... ...
@@ -609,7 +633,6 @@ func TestFullExample(t *testing.T) {
609 609
 	workingDir, err := os.Getwd()
610 610
 	assert.NoError(t, err)
611 611
 
612
-	homeDir := os.Getenv("HOME")
613 612
 	stopGracePeriod := time.Duration(20 * time.Second)
614 613
 
615 614
 	expectedServiceConfig := types.ServiceConfig{
... ...
@@ -657,13 +680,11 @@ func TestFullExample(t *testing.T) {
657 657
 		DNSSearch:  []string{"dc1.example.com", "dc2.example.com"},
658 658
 		DomainName: "foo.com",
659 659
 		Entrypoint: []string{"/code/entrypoint.sh", "-p", "3000"},
660
-		Environment: map[string]string{
661
-			"RACK_ENV":       "development",
662
-			"SHOW":           "true",
663
-			"SESSION_SECRET": "",
664
-			"FOO":            "1",
665
-			"BAR":            "2",
666
-			"BAZ":            "3",
660
+		Environment: map[string]*string{
661
+			"FOO": strPtr("foo_from_env_file"),
662
+			"BAR": strPtr("bar_from_env_file_2"),
663
+			"BAZ": strPtr("baz_from_service_def"),
664
+			"QUX": strPtr("qux_from_environment"),
667 665
 		},
668 666
 		EnvFile: []string{
669 667
 			"./example1.env",
... ...
@@ -955,15 +976,6 @@ func TestFullExample(t *testing.T) {
955 955
 	assert.Equal(t, expectedVolumeConfig, config.Volumes)
956 956
 }
957 957
 
958
-func loadYAML(yaml string) (*types.Config, error) {
959
-	dict, err := ParseYAML([]byte(yaml))
960
-	if err != nil {
961
-		return nil, err
962
-	}
963
-
964
-	return Load(buildConfigDetails(dict))
965
-}
966
-
967 958
 func serviceSort(services []types.ServiceConfig) []types.ServiceConfig {
968 959
 	sort.Sort(servicesByName(services))
969 960
 	return services
... ...
@@ -99,7 +99,7 @@ type ServiceConfig struct {
99 99
 	HealthCheck     *HealthCheckConfig
100 100
 	Image           string
101 101
 	Ipc             string
102
-	Labels          MappingWithEquals
102
+	Labels          Labels
103 103
 	Links           []string
104 104
 	Logging         *LoggingConfig
105 105
 	MacAddress      string `mapstructure:"mac_address"`
... ...
@@ -134,8 +134,13 @@ type StringList []string
134 134
 type StringOrNumberList []string
135 135
 
136 136
 // MappingWithEquals is a mapping type that can be converted from a list of
137
-// key=value strings
138
-type MappingWithEquals map[string]string
137
+// key[=value] strings.
138
+// For the key with an empty value (`key=`), the mapped value is set to a pointer to `""`.
139
+// For the key without value (`key`), the mapped value is set to nil.
140
+type MappingWithEquals map[string]*string
141
+
142
+// Labels is a mapping type for labels
143
+type Labels map[string]string
139 144
 
140 145
 // MappingWithColon is a mapping type that can be converted from a list of
141 146
 // 'key: value' strings
... ...
@@ -151,7 +156,7 @@ type LoggingConfig struct {
151 151
 type DeployConfig struct {
152 152
 	Mode          string
153 153
 	Replicas      *uint64
154
-	Labels        MappingWithEquals
154
+	Labels        Labels
155 155
 	UpdateConfig  *UpdateConfig `mapstructure:"update_config"`
156 156
 	Resources     Resources
157 157
 	RestartPolicy *RestartPolicy `mapstructure:"restart_policy"`
... ...
@@ -268,7 +273,7 @@ type NetworkConfig struct {
268 268
 	External   External
269 269
 	Internal   bool
270 270
 	Attachable bool
271
-	Labels     MappingWithEquals
271
+	Labels     Labels
272 272
 }
273 273
 
274 274
 // IPAMConfig for a network
... ...
@@ -287,7 +292,7 @@ type VolumeConfig struct {
287 287
 	Driver     string
288 288
 	DriverOpts map[string]string `mapstructure:"driver_opts"`
289 289
 	External   External
290
-	Labels     MappingWithEquals
290
+	Labels     Labels
291 291
 }
292 292
 
293 293
 // External identifies a Volume or Network as a reference to a resource that is
... ...
@@ -301,5 +306,5 @@ type External struct {
301 301
 type SecretConfig struct {
302 302
 	File     string
303 303
 	External External
304
-	Labels   MappingWithEquals
304
+	Labels   Labels
305 305
 }