Repository: compose-spec/compose-go
Pull request #219 (2022-01-14 16:14:38)

Fix default values on variables interpolation
Handles extra closing braces by distinguishing it's chooser from the rest of the braces

Resolves https://github.com/docker/compose/issues/9024
Resolves https://github.com/docker/compose/issues/8799


Patch:
diff --git a/.gitignore b/.gitignore
index 828d94e..ad5395b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,2 @@
 ### IDEs ###
 .idea/*
-.vscode/*
diff --git a/go.mod b/go.mod
index 7a0d18a..ddbd482 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	github.com/sirupsen/logrus v1.8.1
 	github.com/xeipuuv/gojsonschema v1.2.0
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
+	golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect
 	gopkg.in/yaml.v2 v2.4.0
-	gotest.tools/v3 v3.1.0
+	gotest.tools/v3 v3.0.3
 )
diff --git a/go.sum b/go.sum
index 3b57ca1..a51dac7 100644
--- a/go.sum
+++ b/go.sum
@@ -41,6 +41,7 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -77,6 +78,7 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -110,29 +112,23 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
 github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
 github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
 github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -142,19 +138,12 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
@@ -168,5 +157,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk=
-gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
diff --git a/loader/interpolate.go b/loader/interpolate.go
index e7ff6f9..76839a6 100644
--- a/loader/interpolate.go
+++ b/loader/interpolate.go
@@ -21,6 +21,7 @@ import (
 	"strings"
 
 	interp "github.com/compose-spec/compose-go/interpolation"
+	"github.com/compose-spec/compose-go/types"
 	"github.com/pkg/errors"
 )
 
@@ -93,11 +94,19 @@ func toInt64(value string) (interface{}, error) {
 }
 
 func toUnitBytes(value string) (interface{}, error) {
-	return transformSize(value)
+	i, err := strconv.ParseInt(value, 10, 64)
+	if err != nil {
+		return nil, err
+	}
+	return types.UnitBytes(i), nil
 }
 
 func toDuration(value string) (interface{}, error) {
-	return transformStringToDuration(value)
+	i, err := strconv.ParseInt(value, 10, 64)
+	if err != nil {
+		return nil, err
+	}
+	return types.Duration(i), nil
 }
 
 func toFloat(value string) (interface{}, error) {
diff --git a/loader/loader.go b/loader/loader.go
index 42a27ec..0093851 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -1045,8 +1045,6 @@ var transformStringToDuration TransformerFunc = func(value interface{}) (interfa
 			return value, err
 		}
 		return types.Duration(d), nil
-	case types.Duration:
-		return value, nil
 	default:
 		return value, errors.Errorf("invalid type %T for duration", value)
 	}
diff --git a/template/template.go b/template/template.go
index ecb7358..22e4e95 100644
--- a/template/template.go
+++ b/template/template.go
@@ -26,6 +26,7 @@ import (
 
 var delimiter = "\\$"
 var substitutionNamed = "[_a-z][_a-z0-9]*"
+
 var substitutionBraced = "[_a-z][_a-z0-9]*(?::?[-?](.*}|[^}]*))?"
 
 var patternString = fmt.Sprintf(
@@ -60,15 +61,17 @@ type SubstituteFunc func(string, Mapping) (string, bool, error)
 // It accepts additional substitute function.
 func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) {
 	if len(subsFuncs) == 0 {
-		subsFuncs = []SubstituteFunc{
-			softDefault,
-			hardDefault,
-			requiredNonEmpty,
-			required,
-		}
+		subsFuncs = getDefaultSortedSubstitutionFunctions(template)
 	}
 	var err error
 	result := pattern.ReplaceAllStringFunc(template, func(substring string) string {
+		closingBraceIndex := getFirstBraceClosingIndex(substring)
+		rest := ""
+		if closingBraceIndex > -1 {
+			rest = substring[closingBraceIndex+1:]
+			substring = substring[0 : closingBraceIndex+1]
+		}
+
 		matches := pattern.FindStringSubmatch(substring)
 		groups := matchGroups(matches, pattern)
 		if escaped := groups["escaped"]; escaped != "" {
@@ -100,7 +103,11 @@ func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, su
 				if !applied {
 					continue
 				}
-				return value
+				interpolatedNested, err := SubstituteWith(rest, mapping, pattern, subsFuncs...)
+				if err != nil {
+					return ""
+				}
+				return value + interpolatedNested
 			}
 		}
 
@@ -114,6 +121,42 @@ func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, su
 	return result, err
 }
 
+func getDefaultSortedSubstitutionFunctions(template string, fns ...SubstituteFunc) []SubstituteFunc {
+	hyphenIndex := strings.IndexByte(template, '-')
+	questionIndex := strings.IndexByte(template, '?')
+	if hyphenIndex < 0 || hyphenIndex > questionIndex {
+		return []SubstituteFunc{
+			requiredNonEmpty,
+			required,
+			softDefault,
+			hardDefault,
+		}
+	}
+	return []SubstituteFunc{
+		softDefault,
+		hardDefault,
+		requiredNonEmpty,
+		required,
+	}
+}
+
+func getFirstBraceClosingIndex(s string) int {
+	openVariableBraces := 0
+	for i := 0; i < len(s); i++ {
+		if s[i] == '}' {
+			openVariableBraces--
+			if openVariableBraces == 0 {
+				return i
+			}
+		}
+		if strings.HasPrefix(s[i:], "${") {
+			openVariableBraces++
+			i++
+		}
+	}
+	return -1
+}
+
 // Substitute variables in the string with their values
 func Substitute(template string, mapping Mapping) (string, error) {
 	return SubstituteWith(template, mapping, defaultPattern)


Test patch:
diff --git a/interpolation/interpolation_test.go b/interpolation/interpolation_test.go
index 0752704..ce1530c 100644
--- a/interpolation/interpolation_test.go
+++ b/interpolation/interpolation_test.go
@@ -17,9 +17,9 @@
 package interpolation
 
 import (
-	"testing"
-
+	"fmt"
 	"strconv"
+	"testing"
 
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
@@ -95,6 +95,96 @@ func TestInterpolateWithDefaults(t *testing.T) {
 	assert.Check(t, is.DeepEqual(expected, result))
 }
 
+func TestValidUnexistentInterpolation(t *testing.T) {
+	var testcases = []struct {
+		test     string
+		expected string
+		errMsg   string
+	}{
+		{test: "{{{ ${FOO:-foo_} }}}", expected: "{{{ foo_ }}}"},
+		{test: "{{{ ${FOO:-foo-bar-value} }}}", expected: "{{{ foo-bar-value }}}"},
+		{test: "{{{ ${FOO:-foo?bar?value} }}}", expected: "{{{ foo?bar?value }}}"},
+		{test: "{{{ ${FOO:-foo:?bar:?value} }}}", expected: "{{{ foo:?bar:?value }}}"},
+		{test: "{{{ ${FOO:-foo} ${BAR:-DEFAULT_VALUE} }}}", expected: "{{{ foo DEFAULT_VALUE }}}"},
+		{test: "{{{ ${BAR} }}}", expected: "{{{  }}}"},
+		{test: "${FOO:-baz} }}}", expected: "baz }}}"},
+		{test: "${FOO-baz} }}}", expected: "baz }}}"},
+
+		{test: "{{{ ${FOO:?foo_} }}}", errMsg: "foo_"},
+		{test: "{{{ ${FOO:?foo-bar-value} }}}", errMsg: "foo-bar-value"},
+		{test: "{{{ ${FOO:?foo} ${BAR:-DEFAULT_VALUE} }}}", errMsg: "foo"},
+		{test: "{{{ ${BAR} }}}", expected: "{{{  }}}"},
+		{test: "${FOO:?baz} }}}", errMsg: "baz"},
+		{test: "${FOO?baz} }}}", errMsg: "baz"},
+	}
+
+	getServiceConfig := func(val string) map[string]interface{} {
+		if val == "" {
+			return map[string]interface{}{}
+		}
+		return map[string]interface{}{
+			"myservice": map[string]interface{}{
+				"environment": map[string]interface{}{
+					"TESTVAR": val,
+				},
+			},
+		}
+	}
+
+	getFullErrorMsg := func(msg string) string {
+		return fmt.Sprintf("invalid interpolation format for myservice.environment.TESTVAR: "+
+			"\"required variable FOO is missing a value: %s\". You may need to escape any $ with another $.", msg)
+	}
+
+	for _, testcase := range testcases {
+		result, err := Interpolate(getServiceConfig(testcase.test), Options{})
+		if testcase.errMsg != "" {
+			assert.Assert(t, err != nil, fmt.Sprintf("This should result in an error %q", testcase.errMsg))
+			assert.Equal(t, getFullErrorMsg(testcase.errMsg), err.Error())
+		}
+		assert.Check(t, is.DeepEqual(getServiceConfig(testcase.expected), result))
+	}
+}
+
+func TestValidExistentInterpolation(t *testing.T) {
+	var testcases = []struct {
+		test     string
+		expected string
+	}{
+		// Only FOO is set
+		{test: "{{{ ${FOO:-foo_} }}}", expected: "{{{ bar }}}"},
+		{test: "{{{ ${FOO:-foo-bar-value} }}}", expected: "{{{ bar }}}"},
+		{test: "{{{ ${FOO:-foo} ${BAR:-DEFAULT_VALUE} }}}", expected: "{{{ bar DEFAULT_VALUE }}}"},
+		{test: "{{{ ${BAR} }}}", expected: "{{{  }}}"},
+		{test: "${FOO:-baz} }}}", expected: "bar }}}"},
+		{test: "${FOO-baz} }}}", expected: "bar }}}"},
+
+		// Both FOO and USER are set
+		{test: "{{{ ${FOO:-foo_} }}}", expected: "{{{ bar }}}"},
+		{test: "{{{ ${FOO:-foo-bar-value} }}}", expected: "{{{ bar }}}"},
+		{test: "{{{ ${FOO:-foo} ${USER:-bar} }}}", expected: "{{{ bar jenny }}}"},
+		{test: "{{{ ${USER} }}}", expected: "{{{ jenny }}}"},
+		{test: "${FOO:-baz} }}}", expected: "bar }}}"},
+		{test: "${FOO-baz} }}}", expected: "bar }}}"},
+	}
+
+	getServiceConfig := func(val string) map[string]interface{} {
+		return map[string]interface{}{
+			"myservice": map[string]interface{}{
+				"environment": map[string]interface{}{
+					"TESTVAR": val,
+				},
+			},
+		}
+	}
+
+	for _, testcase := range testcases {
+		result, err := Interpolate(getServiceConfig(testcase.test), Options{LookupValue: defaultMapping})
+		assert.NilError(t, err)
+		assert.Check(t, is.DeepEqual(getServiceConfig(testcase.expected), result))
+	}
+}
+
 func TestInterpolateWithCast(t *testing.T) {
 	config := map[string]interface{}{
 		"foo": map[string]interface{}{
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 9c97f1b..8abf0c9 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -601,8 +601,7 @@ services:
         soft: $theint
     privileged: $thebool
     read_only: $thebool
-    shm_size: ${thesize}
-    stop_grace_period: ${theduration}
+    shm_size: 2gb
     stdin_open: ${thebool}
     tty: $thebool
     volumes:
@@ -629,11 +628,9 @@ networks:
   back:
 `
 	env := map[string]string{
-		"theint":      "555",
-		"thefloat":    "3.14",
-		"thebool":     "true",
-		"theduration": "60s",
-		"thesize":     "2gb",
+		"theint":   "555",
+		"thefloat": "3.14",
+		"thebool":  "true",
 	}
 
 	config, err := Load(buildConfigDetails(dict, env), func(options *Options) {
@@ -644,12 +641,9 @@ networks:
 
 	workingDir, err := os.Getwd()
 	assert.NilError(t, err)
-	duration, err := time.ParseDuration("60s")
-	assert.NilError(t, err)
-	typesDuration := types.Duration(duration)
 	expected := &types.Project{
 		Name:        "",
-		Environment: env,
+		Environment: map[string]string{"thebool": "true", "thefloat": "3.14", "theint": "555"},
 		WorkingDir:  workingDir,
 		Services: []types.ServiceConfig{
 			{
@@ -696,13 +690,12 @@ networks:
 					"nproc":  {Single: 555},
 					"nofile": {Hard: 555, Soft: 555},
 				},
-				Privileged:      true,
-				ReadOnly:        true,
-				Scale:           1,
-				ShmSize:         types.UnitBytes(2 * 1024 * 1024 * 1024),
-				StopGracePeriod: &typesDuration,
-				StdinOpen:       true,
-				Tty:             true,
+				Privileged: true,
+				ReadOnly:   true,
+				Scale:      1,
+				ShmSize:    types.UnitBytes(2 * 1024 * 1024 * 1024),
+				StdinOpen:  true,
+				Tty:        true,
 				Volumes: []types.ServiceVolumeConfig{
 					{
 						Source:   "data",
diff --git a/template/template_test.go b/template/template_test.go
index ef8f107..21c6008 100644
--- a/template/template_test.go
+++ b/template/template_test.go
@@ -262,6 +262,48 @@ func TestSubstituteWithCustomFunc(t *testing.T) {
 	assert.Check(t, is.ErrorContains(err, "required variable"))
 }
 
+// TestPrecedence tests is the precedence on '-' and '?' is of the first match
+func TestPrecedence(t *testing.T) {
+
+	testCases := []struct {
+		template string
+		expected string
+		err      error
+	}{
+		{
+			template: "${UNSET_VAR?bar-baz}", // Unexistent variable
+			expected: "",
+			err: &InvalidTemplateError{
+				Template: "required variable UNSET_VAR is missing a value: bar-baz",
+			},
+		},
+		{
+			template: "${UNSET_VAR-myerror?msg}", // Unexistent variable
+			expected: "myerror?msg",
+			err:      nil,
+		},
+
+		{
+			template: "${FOO?bar-baz}", // Existent variable
+			expected: "first",
+		},
+		{
+			template: "${BAR:-default_value_for_empty_var}", // Existent empty variable
+			expected: "default_value_for_empty_var",
+		},
+		{
+			template: "${UNSET_VAR-default_value_for_unset_var}", // Unset variable
+			expected: "default_value_for_unset_var",
+		},
+	}
+
+	for _, tc := range testCases {
+		result, err := Substitute(tc.template, defaultMapping)
+		assert.Check(t, is.DeepEqual(tc.err, err))
+		assert.Check(t, is.Equal(tc.expected, result))
+	}
+}
+
 func TestExtractVariables(t *testing.T) {
 	testCases := []struct {
 		name     string