Skip to content
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
7 changes: 6 additions & 1 deletion cli/compose/interpolation/interpolation.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Options struct {
LookupValue LookupValue
// TypeCastMapping maps key paths to functions to cast to a type
TypeCastMapping map[Path]Cast
// Substitution function to use
Substitute func(string, template.Mapping) (string, error)
}

// LookupValue is a function which maps from variable names to values.
Expand All @@ -33,6 +35,9 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf
if opts.TypeCastMapping == nil {
opts.TypeCastMapping = make(map[Path]Cast)
}
if opts.Substitute == nil {
opts.Substitute = template.Substitute
}

out := map[string]interface{}{}

Expand All @@ -51,7 +56,7 @@ func recursiveInterpolate(value interface{}, path Path, opts Options) (interface
switch value := value.(type) {

case string:
newValue, err := template.Substitute(value, template.Mapping(opts.LookupValue))
newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue))
if err != nil || newValue == value {
return value, newPathError(path, err)
}
Expand Down
9 changes: 2 additions & 7 deletions cli/compose/loader/interpolate.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ func toBoolean(value string) (interface{}, error) {
}
}

func interpolateConfig(configDict map[string]interface{}, lookupEnv interp.LookupValue) (map[string]interface{}, error) {
return interp.Interpolate(
configDict,
interp.Options{
LookupValue: lookupEnv,
TypeCastMapping: interpolateTypeCastMapping,
})
func interpolateConfig(configDict map[string]interface{}, opts interp.Options) (map[string]interface{}, error) {
return interp.Interpolate(configDict, opts)
}
41 changes: 34 additions & 7 deletions cli/compose/loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sort"
"strings"

interp "github.com/docker/cli/cli/compose/interpolation"
"github.com/docker/cli/cli/compose/schema"
"github.com/docker/cli/cli/compose/template"
"github.com/docker/cli/cli/compose/types"
Expand All @@ -22,6 +23,16 @@ import (
yaml "gopkg.in/yaml.v2"
)

// Options supported by Load
type Options struct {
// Skip schema validation
SkipValidation bool
// Skip interpolation
SkipInterpolation bool
// Interpolation options
Interpolate *interp.Options
}

// ParseYAML reads the bytes from a file, parses the bytes into a mapping
// structure, and returns it.
func ParseYAML(source []byte) (map[string]interface{}, error) {
Expand All @@ -41,12 +52,25 @@ func ParseYAML(source []byte) (map[string]interface{}, error) {
}

// Load reads a ConfigDetails and returns a fully loaded configuration
func Load(configDetails types.ConfigDetails) (*types.Config, error) {
func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.Config, error) {
if len(configDetails.ConfigFiles) < 1 {
return nil, errors.Errorf("No files specified")
}

opts := &Options{
Interpolate: &interp.Options{
Substitute: template.Substitute,
LookupValue: configDetails.LookupEnv,
TypeCastMapping: interpolateTypeCastMapping,
},
}

for _, op := range options {
op(opts)
}

configs := []*types.Config{}
var err error

for _, file := range configDetails.ConfigFiles {
configDict := file.Config
Expand All @@ -62,14 +86,17 @@ func Load(configDetails types.ConfigDetails) (*types.Config, error) {
return nil, err
}

var err error
configDict, err = interpolateConfig(configDict, configDetails.LookupEnv)
if err != nil {
return nil, err
if !opts.SkipInterpolation {
configDict, err = interpolateConfig(configDict, *opts.Interpolate)
if err != nil {
return nil, err
}
}

if err := schema.Validate(configDict, configDetails.Version); err != nil {
return nil, err
if !opts.SkipValidation {
Copy link
Member

Choose a reason for hiding this comment

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

I guess the reason to allow skipping "validation" is that if "interpolation" is skipped, there's values that are invalid (because, e.g. $port is not a valid port-number)?

Is there a use-case to disable validation or interpolation separately?

Also, trying to think of situations where skipping validation could skip important checks (although, I guess in the end that's the daemon/API's responsibility)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If we don't interpolate, for sure we don't want to validate (otherwise the composefile could be invalid). The other way could be happens, i.e. doing some interpolation but not validating (for whatever reason, like if it's later on used with something else).

Also, trying to think of situations where skipping validation could skip important checks (although, I guess in the end that's the daemon/API's responsibility)

Yes. And also, this is only in the library part of cli/compose — i.e. with this PR it is not possible to not validate or interpolate (and we shouldn't allow that with the docker cli) 😉

if err := schema.Validate(configDict, configDetails.Version); err != nil {
return nil, err
}
}

cfg, err := loadSections(configDict, configDetails)
Expand Down
126 changes: 86 additions & 40 deletions cli/compose/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ var patternString = fmt.Sprintf(

var pattern = regexp.MustCompile(patternString)

// DefaultSubstituteFuncs contains the default SubstitueFunc used by the docker cli
var DefaultSubstituteFuncs = []SubstituteFunc{
softDefault,
hardDefault,
requiredNonEmpty,
required,
}

// InvalidTemplateError is returned when a variable template is not in a valid
// format
type InvalidTemplateError struct {
Expand All @@ -32,8 +40,14 @@ func (e InvalidTemplateError) Error() string {
// and the absence of a value.
type Mapping func(string) (string, bool)

// Substitute variables in the string with their values
func Substitute(template string, mapping Mapping) (string, error) {
// SubstituteFunc is a user-supplied function that apply substitution.
// Returns the value as a string, a bool indicating if the function could apply
// the substitution and an error.
type SubstituteFunc func(string, Mapping) (string, bool, error)

// SubstituteWith subsitute variables in the string with their values.
// It accepts additional substitute function.
func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) {
var err error
result := pattern.ReplaceAllStringFunc(template, func(substring string) string {
matches := pattern.FindStringSubmatch(substring)
Expand All @@ -47,49 +61,22 @@ func Substitute(template string, mapping Mapping) (string, error) {
substitution = groups["braced"]
}

switch {

case substitution == "":
if substitution == "" {
err = &InvalidTemplateError{Template: template}
return ""
}

// Soft default (fall back if unset or empty)
case strings.Contains(substitution, ":-"):
name, defaultValue := partition(substitution, ":-")
value, ok := mapping(name)
if !ok || value == "" {
return defaultValue
}
return value

// Hard default (fall back if-and-only-if empty)
case strings.Contains(substitution, "-"):
name, defaultValue := partition(substitution, "-")
value, ok := mapping(name)
if !ok {
return defaultValue
}
return value

case strings.Contains(substitution, ":?"):
name, errorMessage := partition(substitution, ":?")
value, ok := mapping(name)
if !ok || value == "" {
err = &InvalidTemplateError{
Template: fmt.Sprintf("required variable %s is missing a value: %s", name, errorMessage),
}
for _, f := range subsFuncs {
var (
value string
applied bool
)
value, applied, err = f(substitution, mapping)
if err != nil {
Copy link
Member

Choose a reason for hiding this comment

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

Is the error itself discarded?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@thaJeztah it's not if it's the last one to be applied (i.e. if one function doesn't apply, or apply with error, it doesn't necessarly mean others wont).

return ""
}
return value

case strings.Contains(substitution, "?"):
name, errorMessage := partition(substitution, "?")
value, ok := mapping(name)
if !ok {
err = &InvalidTemplateError{
Template: fmt.Sprintf("required variable %s is missing a value: %s", name, errorMessage),
}
return ""
if !applied {
continue
}
return value
}
Expand All @@ -101,6 +88,65 @@ func Substitute(template string, mapping Mapping) (string, error) {
return result, err
}

// Substitute variables in the string with their values
func Substitute(template string, mapping Mapping) (string, error) {
return SubstituteWith(template, mapping, pattern, DefaultSubstituteFuncs...)
}

// Soft default (fall back if unset or empty)
func softDefault(substitution string, mapping Mapping) (string, bool, error) {
if !strings.Contains(substitution, ":-") {
return "", false, nil
}
name, defaultValue := partition(substitution, ":-")
value, ok := mapping(name)
if !ok || value == "" {
return defaultValue, true, nil
}
return value, true, nil
}

// Hard default (fall back if-and-only-if empty)
func hardDefault(substitution string, mapping Mapping) (string, bool, error) {
if !strings.Contains(substitution, "-") {
return "", false, nil
}
name, defaultValue := partition(substitution, "-")
value, ok := mapping(name)
if !ok {
return defaultValue, true, nil
}
return value, true, nil
}

func requiredNonEmpty(substitution string, mapping Mapping) (string, bool, error) {
if !strings.Contains(substitution, ":?") {
return "", false, nil
}
name, errorMessage := partition(substitution, ":?")
value, ok := mapping(name)
if !ok || value == "" {
return "", true, &InvalidTemplateError{
Template: fmt.Sprintf("required variable %s is missing a value: %s", name, errorMessage),
}
}
return value, true, nil
}

func required(substitution string, mapping Mapping) (string, bool, error) {
if !strings.Contains(substitution, "?") {
return "", false, nil
}
name, errorMessage := partition(substitution, "?")
value, ok := mapping(name)
if !ok {
return "", true, &InvalidTemplateError{
Template: fmt.Sprintf("required variable %s is missing a value: %s", name, errorMessage),
}
}
return value, true, nil
}

func matchGroups(matches []string) map[string]string {
groups := make(map[string]string)
for i, name := range pattern.SubexpNames()[1:] {
Expand Down
24 changes: 24 additions & 0 deletions cli/compose/template/template_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package template

import (
"fmt"
"reflect"
"testing"

Expand Down Expand Up @@ -148,3 +149,26 @@ func TestDefaultsForMandatoryVariables(t *testing.T) {
assert.Check(t, is.Equal(tc.expected, result))
}
}

func TestSubstituteWithCustomFunc(t *testing.T) {
errIsMissing := func(substitution string, mapping Mapping) (string, bool, error) {
value, found := mapping(substitution)
if !found {
return "", true, &InvalidTemplateError{
Template: fmt.Sprintf("required variable %s is missing a value", substitution),
}
}
return value, true, nil
}

result, err := SubstituteWith("ok ${FOO}", defaultMapping, pattern, errIsMissing)
assert.NilError(t, err)
assert.Check(t, is.Equal("ok first", result))

result, err = SubstituteWith("ok ${BAR}", defaultMapping, pattern, errIsMissing)
assert.NilError(t, err)
assert.Check(t, is.Equal("ok ", result))

_, err = SubstituteWith("ok ${NOTHERE}", defaultMapping, pattern, errIsMissing)
assert.Check(t, is.ErrorContains(err, "required variable"))
}