Skip to content

Commit 8dc6561

Browse files
committed
introduce DecodeMapstructure to allow type to define custom decode logic
Signed-off-by: Nicolas De Loof <[email protected]>
1 parent 588d586 commit 8dc6561

File tree

8 files changed

+307
-156
lines changed

8 files changed

+307
-156
lines changed

loader/interpolate.go

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
4646
servicePath("deploy", "placement", "max_replicas_per_node"): toInt,
4747
servicePath("healthcheck", "retries"): toInt,
4848
servicePath("healthcheck", "disable"): toBoolean,
49-
servicePath("mem_limit"): toUnitBytes,
50-
servicePath("mem_reservation"): toUnitBytes,
51-
servicePath("memswap_limit"): toUnitBytes,
52-
servicePath("mem_swappiness"): toUnitBytes,
5349
servicePath("oom_kill_disable"): toBoolean,
5450
servicePath("oom_score_adj"): toInt64,
5551
servicePath("pids_limit"): toInt64,
@@ -58,16 +54,13 @@ var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
5854
servicePath("read_only"): toBoolean,
5955
servicePath("scale"): toInt,
6056
servicePath("secrets", tree.PathMatchList, "mode"): toInt,
61-
servicePath("shm_size"): toUnitBytes,
6257
servicePath("stdin_open"): toBoolean,
63-
servicePath("stop_grace_period"): toDuration,
6458
servicePath("tty"): toBoolean,
6559
servicePath("ulimits", tree.PathMatchAll): toInt,
6660
servicePath("ulimits", tree.PathMatchAll, "hard"): toInt,
6761
servicePath("ulimits", tree.PathMatchAll, "soft"): toInt,
6862
servicePath("volumes", tree.PathMatchList, "read_only"): toBoolean,
6963
servicePath("volumes", tree.PathMatchList, "volume", "nocopy"): toBoolean,
70-
servicePath("volumes", tree.PathMatchList, "tmpfs", "size"): toUnitBytes,
7164
iPath("networks", tree.PathMatchAll, "external"): toBoolean,
7265
iPath("networks", tree.PathMatchAll, "internal"): toBoolean,
7366
iPath("networks", tree.PathMatchAll, "attachable"): toBoolean,
@@ -93,14 +86,6 @@ func toInt64(value string) (interface{}, error) {
9386
return strconv.ParseInt(value, 10, 64)
9487
}
9588

96-
func toUnitBytes(value string) (interface{}, error) {
97-
return transformSize(value)
98-
}
99-
100-
func toDuration(value string) (interface{}, error) {
101-
return transformStringToDuration(value)
102-
}
103-
10489
func toFloat(value string) (interface{}, error) {
10590
return strconv.ParseFloat(value, 64)
10691
}

loader/loader.go

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,12 @@ import (
2828
"regexp"
2929
"strconv"
3030
"strings"
31-
"time"
3231

3332
"github.com/compose-spec/compose-go/consts"
3433
interp "github.com/compose-spec/compose-go/interpolation"
3534
"github.com/compose-spec/compose-go/schema"
3635
"github.com/compose-spec/compose-go/template"
3736
"github.com/compose-spec/compose-go/types"
38-
"github.com/docker/go-units"
39-
"github.com/mattn/go-shellwords"
4037
"github.com/mitchellh/mapstructure"
4138
"github.com/pkg/errors"
4239
"github.com/sirupsen/logrus"
@@ -586,7 +583,7 @@ func Transform(source interface{}, target interface{}, additionalTransformers ..
586583
config := &mapstructure.DecoderConfig{
587584
DecodeHook: mapstructure.ComposeDecodeHookFunc(
588585
createTransformHook(additionalTransformers...),
589-
mapstructure.StringToTimeDurationHookFunc()),
586+
decoderHook),
590587
Result: target,
591588
TagName: "yaml",
592589
Metadata: &data,
@@ -611,11 +608,9 @@ func createTransformHook(additionalTransformers ...Transformer) mapstructure.Dec
611608
transforms := map[reflect.Type]func(interface{}) (interface{}, error){
612609
reflect.TypeOf(types.External{}): transformExternal,
613610
reflect.TypeOf(types.HealthCheckTest{}): transformHealthCheckTest,
614-
reflect.TypeOf(types.ShellCommand{}): transformShellCommand,
615611
reflect.TypeOf(types.StringList{}): transformStringList,
616612
reflect.TypeOf(types.Options{}): transformMapStringString,
617613
reflect.TypeOf(types.UlimitsConfig{}): transformUlimits,
618-
reflect.TypeOf(types.UnitBytes(0)): transformSize,
619614
reflect.TypeOf([]types.ServicePortConfig{}): transformServicePort,
620615
reflect.TypeOf(types.ServiceSecretConfig{}): transformFileReferenceConfig,
621616
reflect.TypeOf(types.ServiceConfigObjConfig{}): transformFileReferenceConfig,
@@ -628,7 +623,6 @@ func createTransformHook(additionalTransformers ...Transformer) mapstructure.Dec
628623
reflect.TypeOf(types.HostsList{}): transformMappingOrListFunc(":", false),
629624
reflect.TypeOf(types.ServiceVolumeConfig{}): transformServiceVolumeConfig,
630625
reflect.TypeOf(types.BuildConfig{}): transformBuildConfig,
631-
reflect.TypeOf(types.Duration(0)): transformStringToDuration,
632626
reflect.TypeOf(types.DependsOnConfig{}): transformDependsOnConfig,
633627
reflect.TypeOf(types.ExtendsConfig{}): transformExtendsConfig,
634628
reflect.TypeOf(types.DeviceRequest{}): transformServiceDeviceRequest,
@@ -1312,13 +1306,6 @@ func transformValueToMapEntry(value string, separator string, allowNil bool) (st
13121306
}
13131307
}
13141308

1315-
var transformShellCommand TransformerFunc = func(value interface{}) (interface{}, error) {
1316-
if str, ok := value.(string); ok {
1317-
return shellwords.Parse(str)
1318-
}
1319-
return value, nil
1320-
}
1321-
13221309
var transformHealthCheckTest TransformerFunc = func(data interface{}) (interface{}, error) {
13231310
switch value := data.(type) {
13241311
case string:
@@ -1330,34 +1317,6 @@ var transformHealthCheckTest TransformerFunc = func(data interface{}) (interface
13301317
}
13311318
}
13321319

1333-
var transformSize TransformerFunc = func(value interface{}) (interface{}, error) {
1334-
switch value := value.(type) {
1335-
case int:
1336-
return int64(value), nil
1337-
case int64, types.UnitBytes:
1338-
return value, nil
1339-
case string:
1340-
return units.RAMInBytes(value)
1341-
default:
1342-
return value, errors.Errorf("invalid type for size %T", value)
1343-
}
1344-
}
1345-
1346-
var transformStringToDuration TransformerFunc = func(value interface{}) (interface{}, error) {
1347-
switch value := value.(type) {
1348-
case string:
1349-
d, err := time.ParseDuration(value)
1350-
if err != nil {
1351-
return value, err
1352-
}
1353-
return types.Duration(d), nil
1354-
case types.Duration:
1355-
return value, nil
1356-
default:
1357-
return value, errors.Errorf("invalid type %T for duration", value)
1358-
}
1359-
}
1360-
13611320
func toMapStringString(value map[string]interface{}, allowNil bool) map[string]interface{} {
13621321
output := make(map[string]interface{})
13631322
for key, value := range value {

loader/mapstructure.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package loader
18+
19+
import "reflect"
20+
21+
// comparable to yaml.Unmarshaler, decoder allow a type to define it's own custom logic to convert value
22+
// see https://github.com/mitchellh/mapstructure/pull/294
23+
type decoder interface {
24+
DecodeMapstructure(interface{}) error
25+
}
26+
27+
// see https://github.com/mitchellh/mapstructure/issues/115#issuecomment-735287466
28+
// adapted to support types derived from built-in types, as DecodeMapstructure would not be able to mutate internal
29+
// value, so need to invoke DecodeMapstructure defined by pointer to type
30+
func decoderHook(from reflect.Value, to reflect.Value) (interface{}, error) {
31+
// If the destination implements the decoder interface
32+
u, ok := to.Interface().(decoder)
33+
if !ok {
34+
// for non-struct types we need to invoke func (*type) DecodeMapstructure()
35+
if to.CanAddr() {
36+
pto := to.Addr()
37+
u, ok = pto.Interface().(decoder)
38+
}
39+
if !ok {
40+
return from.Interface(), nil
41+
}
42+
}
43+
// If it is nil and a pointer, create and assign the target value first
44+
if to.Type().Kind() == reflect.Ptr && to.IsNil() {
45+
to.Set(reflect.New(to.Type().Elem()))
46+
u = to.Interface().(decoder)
47+
}
48+
// Call the custom DecodeMapstructure method
49+
if err := u.DecodeMapstructure(from.Interface()); err != nil {
50+
return to.Interface(), err
51+
}
52+
return to.Interface(), nil
53+
}

loader/mapstructure_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package loader
18+
19+
import (
20+
"testing"
21+
22+
"github.com/compose-spec/compose-go/types"
23+
"github.com/mitchellh/mapstructure"
24+
"gotest.tools/v3/assert"
25+
)
26+
27+
func TestDecodeMapStructure(t *testing.T) {
28+
var target types.ServiceConfig
29+
data := mapstructure.Metadata{}
30+
config := &mapstructure.DecoderConfig{
31+
Result: &target,
32+
TagName: "yaml",
33+
Metadata: &data,
34+
DecodeHook: mapstructure.ComposeDecodeHookFunc(decoderHook),
35+
}
36+
decoder, err := mapstructure.NewDecoder(config)
37+
assert.NilError(t, err)
38+
err = decoder.Decode(map[string]interface{}{
39+
"mem_limit": "640k",
40+
"command": "echo hello",
41+
"stop_grace_period": "60s",
42+
"labels": []interface{}{
43+
"FOO=BAR",
44+
},
45+
"deploy": map[string]interface{}{
46+
"labels": map[string]interface{}{
47+
"FOO": "BAR",
48+
"BAZ": nil,
49+
"QIX": 2,
50+
"ZOT": true,
51+
},
52+
},
53+
})
54+
assert.NilError(t, err)
55+
assert.Equal(t, target.MemLimit, types.UnitBytes(640*1024))
56+
assert.DeepEqual(t, target.Command, types.ShellCommand{"echo", "hello"})
57+
assert.Equal(t, *target.StopGracePeriod, types.Duration(60_000_000_000))
58+
assert.DeepEqual(t, target.Labels, types.Labels{"FOO": "BAR"})
59+
assert.DeepEqual(t, target.Deploy.Labels, types.Labels{
60+
"FOO": "BAR",
61+
"BAZ": "",
62+
"QIX": "2",
63+
"ZOT": "true",
64+
})
65+
}

types/bytes.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package types
18+
19+
import (
20+
"fmt"
21+
22+
"github.com/docker/go-units"
23+
)
24+
25+
// UnitBytes is the bytes type
26+
type UnitBytes int64
27+
28+
// MarshalYAML makes UnitBytes implement yaml.Marshaller
29+
func (u UnitBytes) MarshalYAML() (interface{}, error) {
30+
return fmt.Sprintf("%d", u), nil
31+
}
32+
33+
// MarshalJSON makes UnitBytes implement json.Marshaler
34+
func (u UnitBytes) MarshalJSON() ([]byte, error) {
35+
return []byte(fmt.Sprintf(`"%d"`, u)), nil
36+
}
37+
38+
func (u *UnitBytes) DecodeMapstructure(value interface{}) error {
39+
v, err := units.RAMInBytes(fmt.Sprint(value))
40+
*u = UnitBytes(v)
41+
return err
42+
}

types/command.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package types
18+
19+
import "github.com/mattn/go-shellwords"
20+
21+
// ShellCommand is a string or list of string args.
22+
//
23+
// When marshaled to YAML, nil command fields will be omitted if `omitempty`
24+
// is specified as a struct tag. Explicitly empty commands (i.e. `[]` or
25+
// empty string will serialize to an empty array (`[]`).
26+
//
27+
// When marshaled to JSON, the `omitempty` struct must NOT be specified.
28+
// If the command field is nil, it will be serialized as `null`.
29+
// Explicitly empty commands (i.e. `[]` or empty string) will serialize to
30+
// an empty array (`[]`).
31+
//
32+
// The distinction between nil and explicitly empty is important to distinguish
33+
// between an unset value and a provided, but empty, value, which should be
34+
// preserved so that it can override any base value (e.g. container entrypoint).
35+
//
36+
// The different semantics between YAML and JSON are due to limitations with
37+
// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v3 gives
38+
// us more flexibility via the yaml.IsZeroer interface.
39+
//
40+
// In the future, it might make sense to make fields of this type be
41+
// `*ShellCommand` to avoid this situation, but that would constitute a
42+
// breaking change.
43+
type ShellCommand []string
44+
45+
// IsZero returns true if the slice is nil.
46+
//
47+
// Empty (but non-nil) slices are NOT considered zero values.
48+
func (s ShellCommand) IsZero() bool {
49+
// we do NOT want len(s) == 0, ONLY explicitly nil
50+
return s == nil
51+
}
52+
53+
// MarshalYAML returns nil (which will be serialized as `null`) for nil slices
54+
// and delegates to the standard marshaller behavior otherwise.
55+
//
56+
// NOTE: Typically the nil case here is not hit because IsZero has already
57+
// short-circuited marshalling, but this ensures that the type serializes
58+
// accurately if the `omitempty` struct tag is omitted/forgotten.
59+
//
60+
// A similar MarshalJSON() implementation is not needed because the Go stdlib
61+
// already serializes nil slices to `null`, whereas gopkg.in/yaml.v3 by default
62+
// serializes nil slices to `[]`.
63+
func (s ShellCommand) MarshalYAML() (interface{}, error) {
64+
if s == nil {
65+
return nil, nil
66+
}
67+
return []string(s), nil
68+
}
69+
70+
func (s *ShellCommand) DecodeMapstructure(value interface{}) error {
71+
switch v := value.(type) {
72+
case string:
73+
cmd, err := shellwords.Parse(v)
74+
if err != nil {
75+
return err
76+
}
77+
*s = cmd
78+
case []interface{}:
79+
cmd := make([]string, len(v))
80+
for i, s := range v {
81+
cmd[i] = s.(string)
82+
}
83+
*s = cmd
84+
}
85+
return nil
86+
}

0 commit comments

Comments
 (0)