diff --git a/integration/assets/interpolation/docker-compose.yml b/integration/assets/interpolation/docker-compose.yml new file mode 100644 index 000000000..1467a5251 --- /dev/null +++ b/integration/assets/interpolation/docker-compose.yml @@ -0,0 +1,2 @@ +base: + image: $IMAGE diff --git a/integration/basic_test.go b/integration/basic_test.go index 116b388b0..e1079ad38 100644 --- a/integration/basic_test.go +++ b/integration/basic_test.go @@ -49,6 +49,66 @@ func (s *RunSuite) TestHelloWorld(c *C) { c.Assert(cn.Name, Equals, "/"+name) } +func (s *RunSuite) TestInterpolation(c *C) { + os.Setenv("IMAGE", "tianon/true") + + p := s.CreateProjectFromText(c, ` + test: + image: $IMAGE + `) + + name := fmt.Sprintf("%s_%s_1", p, "test") + testContainer := s.GetContainerByName(c, name) + + p = s.CreateProjectFromText(c, ` + reference: + image: tianon/true + `) + + name = fmt.Sprintf("%s_%s_1", p, "reference") + referenceContainer := s.GetContainerByName(c, name) + + c.Assert(testContainer, NotNil) + + c.Assert(referenceContainer.Image, Equals, testContainer.Image) + + os.Unsetenv("IMAGE") +} + +func (s *RunSuite) TestInterpolationWithExtends(c *C) { + os.Setenv("IMAGE", "tianon/true") + os.Setenv("TEST_PORT", "8000") + + p := s.CreateProjectFromText(c, ` + test: + extends: + file: ./assets/interpolation/docker-compose.yml + service: base + ports: + - ${TEST_PORT} + `) + + name := fmt.Sprintf("%s_%s_1", p, "test") + testContainer := s.GetContainerByName(c, name) + + p = s.CreateProjectFromText(c, ` + reference: + image: tianon/true + ports: + - 8000 + `) + + name = fmt.Sprintf("%s_%s_1", p, "reference") + referenceContainer := s.GetContainerByName(c, name) + + c.Assert(testContainer, NotNil) + + c.Assert(referenceContainer.Image, Equals, testContainer.Image) + + os.Unsetenv("TEST_PORT") + os.Unsetenv("IMAGE") +} + func (s *RunSuite) TestUp(c *C) { p := s.ProjectFromText(c, "up", SimpleTemplate) diff --git a/project/interpolation.go b/project/interpolation.go new file mode 100644 index 000000000..133638214 --- /dev/null +++ b/project/interpolation.go @@ -0,0 +1,150 @@ +package project + +import ( + "bytes" + "fmt" + "strings" + + "github.com/Sirupsen/logrus" +) + +func parseVariable(line string, pos int, mapping func(string) string) (string, int, bool) { + var buffer bytes.Buffer + + for ; pos < len(line); pos++ { + c := line[pos] + + switch { + case c == '_' || (c >= 'A' && c <= 'Z'): + buffer.WriteByte(c) + default: + return mapping(buffer.String()), pos - 1, true + } + } + + return mapping(buffer.String()), pos, true +} + +func parseVariableWithBraces(line string, pos int, mapping func(string) string) (string, int, bool) { + var buffer bytes.Buffer + + for ; pos < len(line); pos++ { + c := line[pos] + + switch { + case c == '}': + bufferString := buffer.String() + + if bufferString == "" { + return "", 0, false + } + + return mapping(buffer.String()), pos, true + case c == '_' || (c >= 'A' && c <= 'Z'): + buffer.WriteByte(c) + default: + return "", 0, false + } + } + + return "", 0, false +} + +func parseInterpolationExpression(line string, pos int, mapping func(string) string) (string, int, bool) { + c := line[pos] + + switch { + case c == '$': + return "$", pos, true + case c == '{': + return parseVariableWithBraces(line, pos+1, mapping) + case c >= 'A' && c <= 'Z': + return parseVariable(line, pos, mapping) + default: + return "", 0, false + } +} + +func parseLine(line string, mapping func(string) string) (string, bool) { + var buffer bytes.Buffer + + for pos := 0; pos < len(line); pos++ { + c := line[pos] + switch { + case c == '$': + var replaced string + var success bool + + replaced, pos, success = parseInterpolationExpression(line, pos+1, mapping) + + if !success { + return "", false + } + + buffer.WriteString(replaced) + default: + buffer.WriteByte(c) + } + } + + return buffer.String(), true +} + +func parseConfig(option, service string, data *interface{}, mapping func(string) string) error { + switch typedData := (*data).(type) { + case string: + var success bool + *data, success = parseLine(typedData, mapping) + + if !success { + return fmt.Errorf("Invalid interpolation format for \"%s\" option in service \"%s\": \"%s\"", option, service, typedData) + } + case []interface{}: + for k, v := range typedData { + err := parseConfig(option, service, &v, mapping) + + if err != nil { + return err + } + + typedData[k] = v + } + case map[interface{}]interface{}: + for k, v := range typedData { + err := parseConfig(option, service, &v, mapping) + + if err != nil { + return err + } + + typedData[k] = v + } + } + + return nil +} + +func interpolate(environmentLookup EnvironmentLookup, config *rawServiceMap) error { + for k, v := range *config { + for k2, v2 := range v { + err := parseConfig(k2, k, &v2, func(s string) string { + values := environmentLookup.Lookup(s, k, nil) + + if len(values) == 0 { + logrus.Warnf("The %s variable is not set. Substituting a blank string.", s) + return "" + } + + return strings.Split(values[0], "=")[1] + }) + + if err != nil { + return err + } + + (*config)[k][k2] = v2 + } + } + + return nil +} diff --git a/project/interpolation_test.go b/project/interpolation_test.go new file mode 100644 index 000000000..00d048723 --- /dev/null +++ b/project/interpolation_test.go @@ -0,0 +1,172 @@ +package project + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" + "os" + "testing" +) + +func testInterpolatedLine(t *testing.T, expectedLine, interpolatedLine string, envVariables map[string]string) { + interpolatedLine, _ = parseLine(interpolatedLine, func(s string) string { + return envVariables[s] + }) + + assert.Equal(t, expectedLine, interpolatedLine) +} + +func testInvalidInterpolatedLine(t *testing.T, line string) { + _, success := parseLine(line, func(string) string { + return "" + }) + + assert.Equal(t, false, success) +} + +func TestParseLine(t *testing.T) { + variables := map[string]string{ + "A": "ABC", + "X": "XYZ", + "E": "", + } + + testInterpolatedLine(t, "ABC", "$A", variables) + testInterpolatedLine(t, "ABC", "${A}", variables) + + testInterpolatedLine(t, "ABC DE", "$A DE", variables) + testInterpolatedLine(t, "ABCDE", "${A}DE", variables) + + testInterpolatedLine(t, "$A", "$$A", variables) + testInterpolatedLine(t, "${A}", "$${A}", variables) + + testInterpolatedLine(t, "$ABC", "$$${A}", variables) + testInterpolatedLine(t, "$ABC", "$$$A", variables) + + testInterpolatedLine(t, "ABC XYZ", "$A $X", variables) + testInterpolatedLine(t, "ABCXYZ", "$A$X", variables) + testInterpolatedLine(t, "ABCXYZ", "${A}${X}", variables) + + testInterpolatedLine(t, "", "$B", variables) + testInterpolatedLine(t, "", "${B}", variables) + testInterpolatedLine(t, "", "$ADE", variables) + + testInterpolatedLine(t, "", "$E", variables) + testInterpolatedLine(t, "", "${E}", variables) + + testInvalidInterpolatedLine(t, "${") + testInvalidInterpolatedLine(t, "$}") + testInvalidInterpolatedLine(t, "${}") + testInvalidInterpolatedLine(t, "${ }") + testInvalidInterpolatedLine(t, "${A }") + testInvalidInterpolatedLine(t, "${ A}") + testInvalidInterpolatedLine(t, "${A!}") + testInvalidInterpolatedLine(t, "$!") +} + +type MockEnvironmentLookup struct { + Variables map[string]string +} + +func (m MockEnvironmentLookup) Lookup(key, serviceName string, config *ServiceConfig) []string { + return []string{fmt.Sprintf("%s=%s", key, m.Variables[key])} +} + +func testInterpolatedConfig(t *testing.T, expectedConfig, interpolatedConfig string, envVariables map[string]string) { + for k, v := range envVariables { + os.Setenv(k, v) + } + + expectedConfigBytes := []byte(expectedConfig) + interpolatedConfigBytes := []byte(interpolatedConfig) + + expectedData := make(rawServiceMap) + interpolatedData := make(rawServiceMap) + + yaml.Unmarshal(expectedConfigBytes, &expectedData) + yaml.Unmarshal(interpolatedConfigBytes, &interpolatedData) + + _ = interpolate(MockEnvironmentLookup{envVariables}, &interpolatedData) + + for k := range envVariables { + os.Unsetenv(k) + } + + assert.Equal(t, expectedData, interpolatedData) +} + +func testInvalidInterpolatedConfig(t *testing.T, interpolatedConfig string) { + interpolatedConfigBytes := []byte(interpolatedConfig) + interpolatedData := make(rawServiceMap) + yaml.Unmarshal(interpolatedConfigBytes, &interpolatedData) + + err := interpolate(new(MockEnvironmentLookup), &interpolatedData) + + assert.NotNil(t, err) +} + +func TestInterpolate(t *testing.T) { + testInterpolatedConfig(t, + `web: + # unbracketed name + image: busybox + + # array element + ports: + - "80:8000" + + # dictionary item value + labels: + mylabel: "myvalue" + + # unset value + hostname: "host-" + + # escaped interpolation + command: "${ESCAPED}"`, + `web: + # unbracketed name + image: $IMAGE + + # array element + ports: + - "${HOST_PORT}:8000" + + # dictionary item value + labels: + mylabel: "${LABEL_VALUE}" + + # unset value + hostname: "host-${UNSET_VALUE}" + + # escaped interpolation + command: "$${ESCAPED}"`, map[string]string{ + "IMAGE": "busybox", + "HOST_PORT": "80", + "LABEL_VALUE": "myvalue", + }) + + testInvalidInterpolatedConfig(t, + `web: + image: "${"`) + + testInvalidInterpolatedConfig(t, + `web: + image: busybox + + # array element + ports: + - "${}:8000"`) + + testInvalidInterpolatedConfig(t, + `web: + image: busybox + + # array element + ports: + - "80:8000" + + # dictionary item value + labels: + mylabel: "${ LABEL_VALUE}"`) +} diff --git a/project/merge.go b/project/merge.go index 0ea20348f..ba5045aea 100644 --- a/project/merge.go +++ b/project/merge.go @@ -40,8 +40,13 @@ func Merge(p *Project, bytes []byte) (map[string]*ServiceConfig, error) { logrus.Fatalf("Could not parse config for project %s : %v", p.Name, err) } + err = interpolate(p.context.EnvironmentLookup, &datas) + if err != nil { + return nil, err + } + for name, data := range datas { - data, err := parse(p.context.ConfigLookup, p.File, data, datas) + data, err := parse(p.context.ConfigLookup, p.context.EnvironmentLookup, p.File, data, datas) if err != nil { logrus.Errorf("Failed to parse service %s: %v", name, err) return nil, err @@ -138,7 +143,7 @@ func resolveBuild(inFile string, serviceData rawService) (rawService, error) { return serviceData, nil } -func parse(configLookup ConfigLookup, inFile string, serviceData rawService, datas rawServiceMap) (rawService, error) { +func parse(configLookup ConfigLookup, environmentLookup EnvironmentLookup, inFile string, serviceData rawService, datas rawServiceMap) (rawService, error) { serviceData, err := readEnvFile(configLookup, inFile, serviceData) if err != nil { return nil, err @@ -174,7 +179,7 @@ func parse(configLookup ConfigLookup, inFile string, serviceData rawService, dat if file == "" { if serviceData, ok := datas[service]; ok { - baseService, err = parse(configLookup, inFile, serviceData, datas) + baseService, err = parse(configLookup, environmentLookup, inFile, serviceData, datas) } else { return nil, fmt.Errorf("Failed to find service %s to extend", service) } @@ -190,12 +195,17 @@ func parse(configLookup ConfigLookup, inFile string, serviceData rawService, dat return nil, err } + err = interpolate(environmentLookup, &baseRawServices) + if err != nil { + return nil, err + } + baseService, ok = baseRawServices[service] if !ok { return nil, fmt.Errorf("Failed to find service %s in file %s", service, file) } - baseService, err = parse(configLookup, resolved, baseService, baseRawServices) + baseService, err = parse(configLookup, environmentLookup, resolved, baseService, baseRawServices) } if err != nil {