From a867141201b134ac3eabf702d965445aa21de95c Mon Sep 17 00:00:00 2001 From: boguszj Date: Wed, 26 Feb 2025 23:09:11 +0100 Subject: [PATCH 1/3] Array variables PoC Signed-off-by: boguszj --- arraytemplate/array_template.go | 55 +++++++++++++++++++++++++++++ interpolation/interpolation.go | 55 ++++++++++++++++++++++++----- interpolation/interpolation_test.go | 25 +++++++++---- loader/loader.go | 3 +- 4 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 arraytemplate/array_template.go diff --git a/arraytemplate/array_template.go b/arraytemplate/array_template.go new file mode 100644 index 00000000..f57282e5 --- /dev/null +++ b/arraytemplate/array_template.go @@ -0,0 +1,55 @@ +package arraytemplate + +import ( + "fmt" + "regexp" +) + +type Mapping func(string) (string, bool) + +const ( + maxArraySize = 100 + + substitution = "[_a-zA-Z][_a-zA-Z0-9]*" +) + +var ( + rawPattern = fmt.Sprintf( + "^\\$(?:(%s)\\[\\*]|{(%s)\\[\\*]})$", + substitution, + substitution, + ) + + ArraySubstitutionPattern = regexp.MustCompile(rawPattern) +) + +func Substitute(template string, mapping Mapping) ([]string, error) { + matches := ArraySubstitutionPattern.FindStringSubmatch(template) + prefix, err := findPrefix(matches) + + if err != nil { + return nil, fmt.Errorf("invalid array template: %s", template) + } + + arr := make([]string, 0, maxArraySize) + for i := 0; i < maxArraySize; i++ { + key := fmt.Sprintf("%s[%d]", prefix, i) + value, ok := mapping(key) + if !ok { + break + } + arr = append(arr, value) + } + + return arr, nil +} + +func findPrefix(matches []string) (string, error) { + for _, match := range matches[1:] { + if match != "" { + return match, nil + } + } + + return "", fmt.Errorf("could not find a match") +} diff --git a/interpolation/interpolation.go b/interpolation/interpolation.go index b56e0afe..93532ef9 100644 --- a/interpolation/interpolation.go +++ b/interpolation/interpolation.go @@ -19,7 +19,9 @@ package interpolation import ( "errors" "fmt" + "github.com/compose-spec/compose-go/v2/arraytemplate" "os" + "reflect" "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/tree" @@ -32,7 +34,12 @@ type Options struct { // TypeCastMapping maps key paths to functions to cast to a type TypeCastMapping map[tree.Path]Cast // Substitution function to use - Substitute func(string, template.Mapping) (string, error) + Substitute func(string, LookupValue) (*SubstitutionResult, error) +} + +type SubstitutionResult struct { + String string + Array []string } // LookupValue is a function which maps from variable names to values. @@ -53,7 +60,7 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf opts.TypeCastMapping = make(map[tree.Path]Cast) } if opts.Substitute == nil { - opts.Substitute = template.Substitute + opts.Substitute = DefaultSubstitute } out := map[string]interface{}{} @@ -69,18 +76,30 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf return out, nil } +func DefaultSubstitute(t string, lookup LookupValue) (*SubstitutionResult, error) { + if (arraytemplate.ArraySubstitutionPattern).MatchString(t) { + arr, err := arraytemplate.Substitute(t, arraytemplate.Mapping(lookup)) + return &SubstitutionResult{Array: arr}, err + } + str, err := template.Substitute(t, template.Mapping(lookup)) + return &SubstitutionResult{String: str}, err +} + func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (interface{}, error) { switch value := value.(type) { case string: - newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue)) + result, err := opts.Substitute(value, opts.LookupValue) if err != nil { return value, newPathError(path, err) } + if result.Array != nil { + return result.Array, nil + } caster, ok := opts.getCasterForPath(path) if !ok { - return newValue, nil + return result.String, nil } - casted, err := caster(newValue) + casted, err := caster(result.String) if err != nil { return casted, newPathError(path, fmt.Errorf("failed to cast to expected type: %w", err)) } @@ -98,13 +117,19 @@ func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (inte return out, nil case []interface{}: - out := make([]interface{}, len(value)) - for i, elem := range value { + out := make([]interface{}, 0, len(value)) + for _, elem := range value { interpolatedElem, err := recursiveInterpolate(elem, path.Next(tree.PathMatchList), opts) if err != nil { return nil, err } - out[i] = interpolatedElem + if isStringSlice(interpolatedElem) { + for _, nestedElem := range interpolatedElem.([]string) { + out = append(out, nestedElem) + } + } else { + out = append(out, interpolatedElem) + } } return out, nil @@ -113,6 +138,20 @@ func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (inte } } +func isStringSlice(value interface{}) bool { + if value == nil { + return false + } + t := reflect.TypeOf(value) + if t.Kind() != reflect.Slice { + return false + } + if t.Elem().Kind() != reflect.String { + return false + } + return true +} + func newPathError(path tree.Path, err error) error { var ite *template.InvalidTemplateError switch { diff --git a/interpolation/interpolation_test.go b/interpolation/interpolation_test.go index f9860ca7..517267cc 100644 --- a/interpolation/interpolation_test.go +++ b/interpolation/interpolation_test.go @@ -27,9 +27,12 @@ import ( ) var defaults = map[string]string{ - "USER": "jenny", - "FOO": "bar", - "count": "5", + "USER": "jenny", + "FOO": "bar", + "count": "5", + "ARR[0]": "zero", + "ARR[1]": "one", + "ARR[2]": "two", } func defaultMapping(name string) (string, bool) { @@ -40,8 +43,11 @@ func defaultMapping(name string) (string, bool) { func TestInterpolate(t *testing.T) { services := map[string]interface{}{ "servicea": map[string]interface{}{ - "image": "example:${USER}", - "volumes": []interface{}{"$FOO:/target"}, + "image": "example:${USER}", + "volumes": []interface{}{ + "$FOO:/target", + "$ARR[*]", + }, "logging": map[string]interface{}{ "driver": "${FOO}", "options": map[string]interface{}{ @@ -52,8 +58,13 @@ func TestInterpolate(t *testing.T) { } expected := map[string]interface{}{ "servicea": map[string]interface{}{ - "image": "example:jenny", - "volumes": []interface{}{"bar:/target"}, + "image": "example:jenny", + "volumes": []interface{}{ + "bar:/target", + "zero", + "one", + "two", + }, "logging": map[string]interface{}{ "driver": "bar", "options": map[string]interface{}{ diff --git a/loader/loader.go b/loader/loader.go index 322109b1..d669436b 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -35,7 +35,6 @@ import ( "github.com/compose-spec/compose-go/v2/override" "github.com/compose-spec/compose-go/v2/paths" "github.com/compose-spec/compose-go/v2/schema" - "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/transform" "github.com/compose-spec/compose-go/v2/tree" "github.com/compose-spec/compose-go/v2/types" @@ -351,7 +350,7 @@ func loadModelWithContext(ctx context.Context, configDetails *types.ConfigDetail func toOptions(configDetails *types.ConfigDetails, options []func(*Options)) *Options { opts := &Options{ Interpolate: &interp.Options{ - Substitute: template.Substitute, + Substitute: interp.DefaultSubstitute, LookupValue: configDetails.LookupEnv, TypeCastMapping: interpolateTypeCastMapping, }, From 4cfd19d6843edc414c89c4fc950c22bc57c90c08 Mon Sep 17 00:00:00 2001 From: boguszj Date: Sat, 1 Mar 2025 22:55:50 +0100 Subject: [PATCH 2/3] Support inlined arrays in env variables Signed-off-by: boguszj --- arraytemplate/array_template.go | 56 +++++++++---- arraytemplate/index_array_template.go | 35 ++++++++ arraytemplate/inlined_array_template.go | 104 ++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 arraytemplate/index_array_template.go create mode 100644 arraytemplate/inlined_array_template.go diff --git a/arraytemplate/array_template.go b/arraytemplate/array_template.go index f57282e5..4cf9f1e8 100644 --- a/arraytemplate/array_template.go +++ b/arraytemplate/array_template.go @@ -1,3 +1,19 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + package arraytemplate import ( @@ -24,32 +40,40 @@ var ( ) func Substitute(template string, mapping Mapping) ([]string, error) { - matches := ArraySubstitutionPattern.FindStringSubmatch(template) - prefix, err := findPrefix(matches) - + arrayName, err := findArrayName(template) + if err != nil { + return nil, err + } + inlined, err := getInlined(arrayName, mapping) if err != nil { - return nil, fmt.Errorf("invalid array template: %s", template) + return nil, fmt.Errorf("could not substitute array template \"%s\":\n%w", template, err) } - arr := make([]string, 0, maxArraySize) - for i := 0; i < maxArraySize; i++ { - key := fmt.Sprintf("%s[%d]", prefix, i) - value, ok := mapping(key) - if !ok { - break - } - arr = append(arr, value) + if inlined != nil { + return inlined, nil } - return arr, nil + return getIndexed(arrayName, mapping), nil } -func findPrefix(matches []string) (string, error) { +func findArrayName(template string) (string, error) { + matches := ArraySubstitutionPattern.FindStringSubmatch(template) + + if len(matches) < 1 { + return "", fmt.Errorf("not a valid array template: \"%s\"", template) + } + + var arrayName string for _, match := range matches[1:] { if match != "" { - return match, nil + arrayName = match + break } } - return "", fmt.Errorf("could not find a match") + if arrayName == "" { + return "", fmt.Errorf("this message suggest an internal error and should never occur; if you see this error, please report it: \"%s\"", template) + } + + return arrayName, nil } diff --git a/arraytemplate/index_array_template.go b/arraytemplate/index_array_template.go new file mode 100644 index 00000000..d196292e --- /dev/null +++ b/arraytemplate/index_array_template.go @@ -0,0 +1,35 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package arraytemplate + +import ( + "fmt" +) + +func getIndexed(arrayName string, mapping Mapping) []string { + arr := make([]string, 0, maxArraySize) + for i := 0; i < maxArraySize; i++ { + key := fmt.Sprintf("%s[%d]", arrayName, i) + value, ok := mapping(key) + if !ok { + break + } + arr = append(arr, value) + } + + return arr +} diff --git a/arraytemplate/inlined_array_template.go b/arraytemplate/inlined_array_template.go new file mode 100644 index 00000000..48e0b355 --- /dev/null +++ b/arraytemplate/inlined_array_template.go @@ -0,0 +1,104 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package arraytemplate + +import ( + "fmt" + "strings" + "unicode" +) + +func getInlined(arrayName string, mapping Mapping) ([]string, error) { + raValue, ok := mapping(arrayName) + if !ok { + return nil, nil + } + + trimmed, err := trim(raValue) + if err != nil { + return nil, formatInlinedArrayParsingError(err, raValue) + } + + result, err := parse(trimmed) + if err != nil { + return nil, formatInlinedArrayParsingError(err, raValue) + } + + return result, nil +} + +func trim(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if len(trimmed) < 2 || trimmed[0] != '(' || trimmed[len(trimmed)-1] != ')' { + return "", fmt.Errorf("should be enclosed in parenthesis") + } + return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), nil +} + +func formatInlinedArrayParsingError(cause error, rawValue string) error { + return fmt.Errorf("invalid array definition: \"%s\" - %w", rawValue, cause) +} + +func parse(value string) ([]string, error) { + var result []string + var current strings.Builder + var inSingleQuote, inDoubleQuote, escaping bool + + for _, char := range value { + switch { + + case escaping: + current.WriteRune(char) + escaping = false + + case char == '(' || char == ')': + return nil, fmt.Errorf("unescaped character (\"%c\")", char) + + case char == '\\': + escaping = true + + case char == '"' && !inSingleQuote: + inDoubleQuote = !inDoubleQuote + + case char == '\'' && !inDoubleQuote: + inSingleQuote = !inSingleQuote + + case unicode.IsSpace(char) && !inSingleQuote && !inDoubleQuote: + if current.Len() > 0 { + result = append(result, current.String()) + current.Reset() + } + + default: + current.WriteRune(char) + } + } + + if current.Len() > 0 { + result = append(result, current.String()) + } + + if inSingleQuote || inDoubleQuote { + return nil, fmt.Errorf("quote not closed") + } + + if escaping { + return nil, fmt.Errorf("nothing left to escape") + } + + return result, nil +} From 004fc247b87c4a0a2dcdac11b7148edb9421cb32 Mon Sep 17 00:00:00 2001 From: boguszj Date: Sat, 1 Mar 2025 22:56:04 +0100 Subject: [PATCH 3/3] Add tests Signed-off-by: boguszj --- arraytemplate/array_template_test.go | 276 +++++++++++++++++++++++++++ interpolation/interpolation.go | 2 +- interpolation/interpolation_test.go | 49 +++-- loader/loader_test.go | 35 +++- 4 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 arraytemplate/array_template_test.go diff --git a/arraytemplate/array_template_test.go b/arraytemplate/array_template_test.go new file mode 100644 index 00000000..f4bef759 --- /dev/null +++ b/arraytemplate/array_template_test.go @@ -0,0 +1,276 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package arraytemplate + +import ( + "fmt" + "gotest.tools/v3/assert" + "testing" +) + +func buildMapping(mapping map[string]string) func(string) (string, bool) { + return func(name string) (string, bool) { + v, ok := mapping[name] + return v, ok + } +} + +func TestIndexedArraysSubstitutions(t *testing.T) { + testcases := []struct { + template string + mapping map[string]string + expectedArray []string + }{ + { + template: "$INDEXED_ARRAY[*]", + mapping: map[string]string{ + "INDEXED_ARRAY[0]": "zero", + "INDEXED_ARRAY[1]": "one", + "INDEXED_ARRAY[2]": "two", + }, + expectedArray: []string{"zero", "one", "two"}, + }, + { + template: "${BRACED_INDEXED_ARRAY[*]}", + mapping: map[string]string{ + "BRACED_INDEXED_ARRAY[0]": "zero", + "BRACED_INDEXED_ARRAY[1]": "one", + "BRACED_INDEXED_ARRAY[2]": "two", + }, + expectedArray: []string{"zero", "one", "two"}, + }, + { + template: "$EMPTY_ARRAY[*]", + mapping: map[string]string{ + "NON_RELEVANT": "WHATEVER", + }, + expectedArray: []string{}, + }, + { + template: "$MISINDEXED_ARRAY[*]", + mapping: map[string]string{ + "MISINDEXED_ARRAY[1]": "zero", + "MISINDEXED_ARRAY[2]": "one", + "MISINDEXED_ARRAY[3]": "two", + }, + expectedArray: []string{}, + }, + { + template: "$INTERRUPTED_ARRAY[*]", + mapping: map[string]string{ + "INTERRUPTED_ARRAY[0]": "zero", + "INTERRUPTED_ARRAY[1": "one", + "INTERRUPTED_ARRAY[2]": "two", + }, + expectedArray: []string{"zero"}, + }, + { + template: "$INVALID_KEY[*]", + mapping: map[string]string{ + "INDEXED_ARRAY[0]": "zero", + "INDEXED_ARRAY[1]": "one", + "INDEXED_ARRAY[2]": "two", + }, + expectedArray: []string{}, + }, + { + template: "$ASTERIX_ARRAY[*]", + mapping: map[string]string{ + "ASTERIX_ARRAY[*]": "asterix", + }, + expectedArray: []string{}, + }, + } + + for _, testcase := range testcases { + mapping := buildMapping(testcase.mapping) + result, err := Substitute(testcase.template, mapping) + assert.NilError(t, err) + assert.DeepEqual(t, testcase.expectedArray, result) + } +} + +func TestInlinedArraysSubstitutions(t *testing.T) { + testcases := []struct { + rawValue string + expectedArray []string + }{ + { + rawValue: "(zero one two)", + expectedArray: []string{"zero", "one", "two"}, + }, + { + rawValue: "()", + expectedArray: []string{}, + }, + { + rawValue: "( )", + expectedArray: []string{}, + }, + { + rawValue: "(\"zero\" \"one\" \"two\")", + expectedArray: []string{"zero", "one", "two"}, + }, + { + rawValue: "('zero' 'one' 'two')", + expectedArray: []string{"zero", "one", "two"}, + }, + { + rawValue: "(\"zero 0\" \"one 1\" \"two 2\")", + expectedArray: []string{"zero 0", "one 1", "two 2"}, + }, + { + rawValue: "(zero\\ 0 one\\ 1 two\\ 2)", + expectedArray: []string{"zero 0", "one 1", "two 2"}, + }, + { + rawValue: "(zero\\ 0 \"one 1\" two\\ 2)", + expectedArray: []string{"zero 0", "one 1", "two 2"}, + }, + { + rawValue: "( zero one two )", + expectedArray: []string{"zero", "one", "two"}, + }, + { + rawValue: "( '\"' )", + expectedArray: []string{"\""}, + }, + { + rawValue: "( \"'\" )", + expectedArray: []string{"'"}, + }, + { + rawValue: "( \\' )", + expectedArray: []string{"'"}, + }, + { + rawValue: "( \\\" )", + expectedArray: []string{"\""}, + }, + { + rawValue: "( \"\\\"\" )", + expectedArray: []string{"\""}, + }, + { + rawValue: "( '\\'' )", + expectedArray: []string{"'"}, + }, + { + rawValue: "(\\\\)", + expectedArray: []string{"\\"}, + }, + } + + for _, testcase := range testcases { + mapping := buildMapping(map[string]string{"arr": testcase.rawValue}) + result, err := Substitute("$arr[*]", mapping) + assert.NilError(t, err) + assert.DeepEqual(t, testcase.expectedArray, result) + } +} + +func TestInlinedPriority(t *testing.T) { + testcases := []struct { + template string + mapping map[string]string + expectedArray []string + }{ + { + template: "$MIXED_ARRAY[*]", + mapping: map[string]string{ + "MIXED_ARRAY": "(zero one two)", + "MIXED_ARRAY[0]": "0", + "MIXED_ARRAY[1]": "1", + "MIXED_ARRAY[2]": "2", + }, + expectedArray: []string{"zero", "one", "two"}, + }, + { + template: "$MIXED_ARRAY[*]", + mapping: map[string]string{ + "MIXED_ARRAY": "(zero one two)", + "MIXED_ARRAY[3]": "3", + }, + expectedArray: []string{"zero", "one", "two"}, + }, + } + + for _, testcase := range testcases { + mapping := buildMapping(testcase.mapping) + result, err := Substitute(testcase.template, mapping) + assert.NilError(t, err) + assert.DeepEqual(t, testcase.expectedArray, result) + } +} + +func TestBadInlinedDeclarations(t *testing.T) { + expectedErrMsg := func(value string, cause string) string { + return fmt.Sprintf( + "could not substitute array template \"$arr[*]\":\ninvalid array definition: \"%s\" - %s", + value, + cause, + ) + } + testcases := []struct { + value string + cause string + }{ + {value: "(", cause: "should be enclosed in parenthesis"}, + {value: ")", cause: "should be enclosed in parenthesis"}, + {value: "abc", cause: "should be enclosed in parenthesis"}, + {value: "(zero", cause: "should be enclosed in parenthesis"}, + {value: "zero)", cause: "should be enclosed in parenthesis"}, + {value: "(zero one", cause: "should be enclosed in parenthesis"}, + {value: "one zero)", cause: "should be enclosed in parenthesis"}, + {value: "(zero one ", cause: "should be enclosed in parenthesis"}, + {value: " one zero)", cause: "should be enclosed in parenthesis"}, + {value: "(\")", cause: "quote not closed"}, + {value: "(')", cause: "quote not closed"}, + {value: "(zero one))", cause: "unescaped character (\")\")"}, + {value: "((zero one)", cause: "unescaped character (\"(\")"}, + {value: "(\"one)", cause: "quote not closed"}, + {value: "(one\")", cause: "quote not closed"}, + {value: "('one)", cause: "quote not closed"}, + {value: "(one')", cause: "quote not closed"}, + {value: "(\\)", cause: "nothing left to escape"}, + } + + for _, testcase := range testcases { + mapping := buildMapping(map[string]string{"arr": testcase.value}) + result, err := Substitute("$arr[*]", mapping) + assert.DeepEqual(t, result, []string(nil)) + assert.Error(t, err, expectedErrMsg(testcase.value, testcase.cause)) + } +} + +func TestBadTemplates(t *testing.T) { + testcases := []string{ + "NO_DOLLAR_SIGN[*]", + "$NO_CLOSING_BRACKET[*", + "$NO_OPENING_BRACKET*]", + "$NO_OPENING_BRACE[*]}", + "${NO_CLOSING_BRACE[*]", + "${INDEX_OUTSIZE_BRACES}[*]", + } + + for _, testcase := range testcases { + neverMapping := func(name string) (string, bool) { return "", false } + result, err := Substitute(testcase, neverMapping) + assert.DeepEqual(t, result, []string(nil)) + assert.Error(t, err, fmt.Sprintf("not a valid array template: \"%s\"", testcase)) + } +} diff --git a/interpolation/interpolation.go b/interpolation/interpolation.go index 93532ef9..42d1773b 100644 --- a/interpolation/interpolation.go +++ b/interpolation/interpolation.go @@ -162,7 +162,7 @@ func newPathError(path tree.Path, err error) error { "invalid interpolation format for %s.\nYou may need to escape any $ with another $.\n%s", path, ite.Template) default: - return fmt.Errorf("error while interpolating %s: %w", path, err) + return fmt.Errorf("error while interpolating %s:\n%w", path, err) } } diff --git a/interpolation/interpolation_test.go b/interpolation/interpolation_test.go index 517267cc..efcdcca8 100644 --- a/interpolation/interpolation_test.go +++ b/interpolation/interpolation_test.go @@ -27,12 +27,13 @@ import ( ) var defaults = map[string]string{ - "USER": "jenny", - "FOO": "bar", - "count": "5", - "ARR[0]": "zero", - "ARR[1]": "one", - "ARR[2]": "two", + "USER": "jenny", + "FOO": "bar", + "count": "5", + "INDEXED_ARRAY[0]": "/zero", + "INDEXED_ARRAY[1]": "/one", + "INDEXED_ARRAY[2]": "/two", + "INLINED_ARRAY": "(8080:80 8443:443)", } func defaultMapping(name string) (string, bool) { @@ -46,8 +47,11 @@ func TestInterpolate(t *testing.T) { "image": "example:${USER}", "volumes": []interface{}{ "$FOO:/target", - "$ARR[*]", + "$INDEXED_ARRAY[*]", + "${INLINED_ARRAY}", }, + "ports": "${INLINED_ARRAY[*]}", + "devices": "${NON_EXISTENT_ARRAY[*]}", "logging": map[string]interface{}{ "driver": "${FOO}", "options": map[string]interface{}{ @@ -61,10 +65,16 @@ func TestInterpolate(t *testing.T) { "image": "example:jenny", "volumes": []interface{}{ "bar:/target", - "zero", - "one", - "two", + "/zero", + "/one", + "/two", + "(8080:80 8443:443)", }, + "ports": []string{ + "8080:80", + "8443:443", + }, + "devices": []string{}, "logging": map[string]interface{}{ "driver": "bar", "options": map[string]interface{}{ @@ -90,6 +100,23 @@ You may need to escape any $ with another $. ${`) } +func TestInvalidInlinedArrayValueInterpolation(t *testing.T) { + services := map[string]interface{}{ + "servicea": map[string]interface{}{ + "volumes": "${INLINED_ARRAY[*]}", + }, + } + + erroneousMapping := func(name string) (string, bool) { + return "('no closing quote)", true + } + + _, err := Interpolate(services, Options{LookupValue: erroneousMapping}) + assert.Error(t, err, `error while interpolating servicea.volumes: +could not substitute array template "${INLINED_ARRAY[*]}": +invalid array definition: "('no closing quote)" - quote not closed`) +} + func TestInterpolateWithDefaults(t *testing.T) { t.Setenv("FOO", "BARZ") @@ -151,7 +178,7 @@ func TestValidUnexistentInterpolation(t *testing.T) { } getFullErrorMsg := func(msg string) string { - return fmt.Sprintf("error while interpolating myservice.environment.TESTVAR: required variable FOO is missing a value: %s", msg) + return fmt.Sprintf("error while interpolating myservice.environment.TESTVAR:\nrequired variable FOO is missing a value: %s", msg) } for _, testcase := range testcases { diff --git a/loader/loader_test.go b/loader/loader_test.go index c0c75656..e649110c 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -667,6 +667,11 @@ services: - home2=${HOME} - nonexistent=$NONEXISTENT - default=${NONEXISTENT-default} + - rawarray=${INLINED_ARRAY} + ports: + - "1000:1000" + - ${INLINED_ARRAY[*]} + - ${INDEXED_ARRAY[*]} networks: test: driver: $HOME @@ -674,8 +679,11 @@ volumes: test: driver: $HOME `, map[string]string{ - "HOME": home, - "FOO": "foo", + "HOME": home, + "FOO": "foo", + "INLINED_ARRAY": "(2000:2000 3000:3000)", + "INDEXED_ARRAY[0]": "4000:4000", + "INDEXED_ARRAY[1]": "5000:5000", }) assert.NilError(t, err) @@ -685,9 +693,19 @@ volumes: "home2": home, "nonexistent": "", "default": "default", + "rawarray": "(2000:2000 3000:3000)", + } + + expectedPorts := []types.ServicePortConfig{ + {Target: 1000, Published: "1000", Mode: "ingress", Protocol: "tcp"}, + {Target: 2000, Published: "2000", Mode: "ingress", Protocol: "tcp"}, + {Target: 3000, Published: "3000", Mode: "ingress", Protocol: "tcp"}, + {Target: 4000, Published: "4000", Mode: "ingress", Protocol: "tcp"}, + {Target: 5000, Published: "5000", Mode: "ingress", Protocol: "tcp"}, } assert.Check(t, is.DeepEqual(expectedLabels, config.Services["test"].Labels)) + assert.Check(t, is.DeepEqual(expectedPorts, config.Services["test"].Ports)) assert.Check(t, is.Equal(home, config.Networks["test"].Driver)) assert.Check(t, is.Equal(home, config.Volumes["test"].Driver)) } @@ -1057,6 +1075,19 @@ func TestInvalidResource(t *testing.T) { assert.ErrorContains(t, err, "Additional property impossible is not allowed") } +func TestInterpolatedArrayWhereStringExpected(t *testing.T) { + _, err := loadYAMLWithEnv(` + name: test + services: + foo: + image: ${INLINED_ARRAY[*]} +`, map[string]string{ + "INLINED_ARRAY": "(busybox)", + }) + + assert.ErrorContains(t, err, "validating filename0.yml: services.foo.image must be a string") +} + func TestInvalidExternalAndDriverCombination(t *testing.T) { _, err := loadYAML(` name: invalid-external-and-driver-combination