Repository: score-spec/score-compose
Pull request #120 (2024-04-21 12:15:48)

fix: workload when container command or args contains `$` variables
A suggestion for solving issue #105 with the `$` variables in the commands and the arguments.

#### Description
Replaces the Dollar sign (`$`) in the variables in the command and argument strings with the double Dollar sign (`$$`).

#### What does this PR do?
Fix for the bug #105 .

#### Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] New chore (expected functionality to be implemented)

#### Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My change requires a change to the documentation.
  - [ ] I have updated the documentation accordingly.
- [x] I've signed off with an email address that matches the commit author.



Patch:
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index 333ba48..4d2f6e6 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -78,6 +78,16 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 			env[key] = &resolved
 		}
 
+		// replace dollar sign ($) by double dollar sign ($$) in command strings
+		if len(cSpec.Command) > 0 {
+			cSpec.Command = util.PrepareEnvVariables(cSpec.Command)
+		}
+
+		// replace dollar sign ($) by double dollar sign ($$) in command arguments
+		if len(cSpec.Args) > 0 {
+			cSpec.Args = util.PrepareEnvVariables(cSpec.Args)
+		}
+
 		var volumes []compose.ServiceVolumeConfig
 		if len(cSpec.Volumes) > 0 {
 			volumes = make([]compose.ServiceVolumeConfig, len(cSpec.Volumes))
diff --git a/internal/util/variables.go b/internal/util/variables.go
new file mode 100644
index 0000000..b6ace96
--- /dev/null
+++ b/internal/util/variables.go
@@ -0,0 +1,38 @@
+// Copyright 2024 Humanitec
+//
+// 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 util
+
+import (
+	"regexp"
+)
+
+// PrepareEnvVariables replaces the dollar sign ($) by double dollar sign ($$)
+// in slice strings for using in the docker compose file
+func PrepareEnvVariables(arr []string) []string {
+	// create pattern matching string
+	re := regexp.MustCompile(`(^|[[:graph:]][^$])\$([\w+|\{?])`)
+
+	// prepare replace string
+	replaceStr := "${1}$$$$${2}"
+
+	for i := range arr {
+		var done bool
+		for !done {
+			arr[i] = re.ReplaceAllString(arr[i], replaceStr)
+			done = !re.MatchString(arr[i])
+		}
+	}
+	return arr
+}


Test patch:
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index d33ebcb..91a6717 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -214,6 +214,49 @@ func TestScoreConvert(t *testing.T) {
 				},
 			},
 		},
+		{
+			Name: "Should convert SCORE to docker-compose spec with env variables",
+			Source: &score.Workload{
+				Metadata: score.WorkloadMetadata{
+					"name": "test",
+				},
+				Containers: score.WorkloadContainers{
+					"backend": score.Container{
+						Image: "busybox",
+						Command: []string{
+							"/bin/sh",
+							"-c",
+						},
+						Args: []string{
+							"echo hello $A ${B} $C world",
+						},
+						Variables: map[string]string{
+							"A": "cat",
+							"B": "dog",
+						},
+					},
+				},
+			},
+			Project: &compose.Project{
+				Services: compose.Services{
+					"test-backend": {
+						Name:  "test-backend",
+						Image: "busybox",
+						Entrypoint: compose.ShellCommand{
+							"/bin/sh",
+							"-c",
+						},
+						Command: compose.ShellCommand{
+							"echo hello $$A $${B} $$C world",
+						},
+						Environment: compose.MappingWithEquals{
+							"A": stringPtr("cat"),
+							"B": stringPtr("dog"),
+						},
+					},
+				},
+			},
+		},
 
 		// Errors handling
 		//
diff --git a/internal/util/variables_test.go b/internal/util/variables_test.go
new file mode 100644
index 0000000..ffd58b9
--- /dev/null
+++ b/internal/util/variables_test.go
@@ -0,0 +1,183 @@
+// Copyright 2024 Humanitec
+//
+// 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 util
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestPrepareEnvVariables(t *testing.T) {
+	tests := []struct {
+		name string
+		arr  []string
+		want []string
+	}{
+		{"PrepareEnvVariables only",
+			[]string{
+				"$A",
+				"$VAR",
+				"${VARIABLE}",
+			},
+			[]string{
+				"$$A",
+				"$$VAR",
+				"$${VARIABLE}",
+			},
+		},
+		{"PrepareEnvVariables with prefix",
+			[]string{
+				"PREFIX-$VAR",
+				"PREFIX-${VARIABLE}",
+				"PREFIX_$VAR",
+				"PREFIX_${VARIABLE}",
+				"PREFIX${VARIABLE}",
+			},
+			[]string{
+				"PREFIX-$$VAR",
+				"PREFIX-$${VARIABLE}",
+				"PREFIX_$$VAR",
+				"PREFIX_$${VARIABLE}",
+				"PREFIX$${VARIABLE}",
+			},
+		},
+		{"PrepareEnvVariables with prefix and special character",
+			[]string{
+				"PREFIX@${VARIABLE}",
+				"PREFIX#${VARIABLE}",
+				"PREFIX!${VARIABLE}",
+				"PREFIX&${VARIABLE}",
+				"PREFIX%${VARIABLE}",
+				"PREFIX:${VARIABLE}",
+				"PREFIX;${VARIABLE}",
+				"PREFIX=${VARIABLE}",
+				"PREFIX|${VARIABLE}",
+				"PREFIX~${VARIABLE}",
+				"'PREFIX'${VARIABLE}",
+				"PREFIX.${VARIABLE}",
+				"PREFIX*${VARIABLE}",
+				"PREFIX?${VARIABLE}",
+				"PREFIX^${VARIABLE}",
+				`PREFIX"${VARIABLE}"`,
+			},
+			[]string{
+				"PREFIX@$${VARIABLE}",
+				"PREFIX#$${VARIABLE}",
+				"PREFIX!$${VARIABLE}",
+				"PREFIX&$${VARIABLE}",
+				"PREFIX%$${VARIABLE}",
+				"PREFIX:$${VARIABLE}",
+				"PREFIX;$${VARIABLE}",
+				"PREFIX=$${VARIABLE}",
+				"PREFIX|$${VARIABLE}",
+				"PREFIX~$${VARIABLE}",
+				"'PREFIX'$${VARIABLE}",
+				"PREFIX.$${VARIABLE}",
+				"PREFIX*$${VARIABLE}",
+				"PREFIX?$${VARIABLE}",
+				"PREFIX^$${VARIABLE}",
+				`PREFIX"$${VARIABLE}"`,
+			},
+		},
+		{"PrepareEnvVariables with prefix and slashes",
+			[]string{
+				"PREFIX/${VARIABLE}",
+				"PREFIX\\${VARIABLE}",
+			},
+			[]string{
+				"PREFIX/$${VARIABLE}",
+				"PREFIX\\$${VARIABLE}",
+			},
+		},
+		{"PrepareEnvVariables with prefix and brackets",
+			[]string{
+				"(PREFIX)${VARIABLE}",
+				"PREFIX(${VARIABLE})",
+				"[PREFIX]${VARIABLE}",
+				"PREFIX[${VARIABLE}]",
+				"{PREFIX}${VARIABLE}",
+				"PREFIX{${VARIABLE}}",
+			},
+			[]string{
+				"(PREFIX)$${VARIABLE}",
+				"PREFIX($${VARIABLE})",
+				"[PREFIX]$${VARIABLE}",
+				"PREFIX[$${VARIABLE}]",
+				"{PREFIX}$${VARIABLE}",
+				"PREFIX{$${VARIABLE}}",
+			},
+		},
+		{"PrepareEnvVariables with suffix",
+			[]string{
+				"$VAR-SUFFIX",
+				"${VARIABLE}-SUFFIX",
+				"$VAR_SUFFIX",
+				"${VARIABLE}_SUFFIX",
+				"${VARIABLE}SUFFIX",
+			},
+			[]string{
+				"$$VAR-SUFFIX",
+				"$${VARIABLE}-SUFFIX",
+				"$$VAR_SUFFIX",
+				"$${VARIABLE}_SUFFIX",
+				"$${VARIABLE}SUFFIX",
+			},
+		}, {"PrepareEnvVariables with one letter",
+			[]string{
+				"echo hello $A ${B} $C world",
+				"echo hello $A-${B}-$C world",
+				"echo hello $A_${B}_$C world",
+				"echo hello $A${B}$C world",
+			},
+			[]string{
+				"echo hello $$A $${B} $$C world",
+				"echo hello $$A-$${B}-$$C world",
+				"echo hello $$A_$${B}_$$C world",
+				"echo hello $$A$${B}$$C world",
+			},
+		},
+		{"PrepareEnvVariables with several letters",
+			[]string{
+				"echo hello $AAA ${BBB} $CCC world",
+				"echo hello $AAA-${BBB}-$CCC world",
+				"echo hello $AAA_${BBB}_$CCC world",
+				"echo hello $AAA${BBB}$CCC world",
+			},
+			[]string{
+				"echo hello $$AAA $${BBB} $$CCC world",
+				"echo hello $$AAA-$${BBB}-$$CCC world",
+				"echo hello $$AAA_$${BBB}_$$CCC world",
+				"echo hello $$AAA$${BBB}$$CCC world",
+			},
+		},
+		{"PrepareEnvVariables complex example",
+			[]string{
+				"curl https://${SUBDOMAIN}.${HOST}:${PORT}/${PATH}?${PARAM1}=${VALUE1}&${PARAM2}=${VALUE2}#${REFID}",
+				"mysql://${USER}:${PASSWORD}@${HOST}:${PORT}/${DATABASE}?${KEY1}=${VALUE1}&${KEY2}=${VALUE2}&${KEY3}=${VALUE3}",
+			},
+			[]string{
+				"curl https://$${SUBDOMAIN}.$${HOST}:$${PORT}/$${PATH}?$${PARAM1}=$${VALUE1}&$${PARAM2}=$${VALUE2}#$${REFID}",
+				"mysql://$${USER}:$${PASSWORD}@$${HOST}:$${PORT}/$${DATABASE}?$${KEY1}=$${VALUE1}&$${KEY2}=$${VALUE2}&$${KEY3}=$${VALUE3}",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := PrepareEnvVariables(tt.arr); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("PrepareEnvVariables()\nget  = %v\nwant = %v\n", got, tt.want)
+			}
+		})
+	}
+}