Skip to content
This repository was archived by the owner on Jun 16, 2021. It is now read-only.

Support interpolating environment variables #47

Merged
merged 1 commit into from
Oct 7, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integration/assets/interpolation/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
base:
image: $IMAGE
60 changes: 60 additions & 0 deletions integration/basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,66 @@ func (s *RunSuite) TestHelloWorld(c *C) {
c.Assert(cn.Name, Equals, "/"+name)
}

func (s *RunSuite) TestInterpolation(c *C) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add another test or include it in this one that validates that interpolation happens for extended templates meaning that the parent and child templates both have variables in them.

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)

Expand Down
150 changes: 150 additions & 0 deletions project/interpolation.go
Original file line number Diff line number Diff line change
@@ -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':
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing lowercase letters, which are valid in variable names.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created #70 to address this.

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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure what to do with the third argument here since a ServiceConfig isn't created until after interpolation is performed. This works fine using the OsEnvLookup implementation, which is currently the only one, but it might not be a good idea in general.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that makes sense. I think it's fine to say that the serviceConfig could be nil and the impl of EnvironmentLookup so be programmed so.


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
}
172 changes: 172 additions & 0 deletions project/interpolation_test.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a testInterpolatedLine(t, "", "$ADE", variables) case


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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, a testInterpolatedLine(t, "ABCXYZ", "$A$X", variables) case

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}"`)
}
Loading