github.com/bdwilliams/libcompose@v0.3.1-0.20160826154243-d81a9bdacff0/config/validation.go (about)

     1  package config
     2  
     3  import (
     4  	"fmt"
     5  	"strconv"
     6  	"strings"
     7  
     8  	"github.com/xeipuuv/gojsonschema"
     9  )
    10  
    11  func serviceNameFromErrorField(field string) string {
    12  	splitKeys := strings.Split(field, ".")
    13  	return splitKeys[0]
    14  }
    15  
    16  func keyNameFromErrorField(field string) string {
    17  	splitKeys := strings.Split(field, ".")
    18  
    19  	if len(splitKeys) > 0 {
    20  		return splitKeys[len(splitKeys)-1]
    21  	}
    22  
    23  	return ""
    24  }
    25  
    26  func containsTypeError(resultError gojsonschema.ResultError) bool {
    27  	contextSplit := strings.Split(resultError.Context().String(), ".")
    28  	_, err := strconv.Atoi(contextSplit[len(contextSplit)-1])
    29  	return err == nil
    30  }
    31  
    32  func addArticle(s string) string {
    33  	switch s[0] {
    34  	case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
    35  		return "an " + s
    36  	default:
    37  		return "a " + s
    38  	}
    39  }
    40  
    41  // Gets the value in a service map at a given error context
    42  func getValue(val interface{}, context string) string {
    43  	keys := strings.Split(context, ".")
    44  
    45  	if keys[0] == "(root)" {
    46  		keys = keys[1:]
    47  	}
    48  
    49  	for i, k := range keys {
    50  		switch typedVal := (val).(type) {
    51  		case string:
    52  			return typedVal
    53  		case []interface{}:
    54  			if index, err := strconv.Atoi(k); err == nil {
    55  				val = typedVal[index]
    56  			}
    57  		case RawServiceMap:
    58  			val = typedVal[k]
    59  		case RawService:
    60  			val = typedVal[k]
    61  		case map[interface{}]interface{}:
    62  			val = typedVal[k]
    63  		}
    64  
    65  		if i == len(keys)-1 {
    66  			return fmt.Sprint(val)
    67  		}
    68  	}
    69  
    70  	return ""
    71  }
    72  
    73  // Converts map[interface{}]interface{} to map[string]interface{} recursively
    74  // gojsonschema only accepts map[string]interface{}
    75  func convertServiceMapKeysToStrings(serviceMap RawServiceMap) RawServiceMap {
    76  	newServiceMap := make(RawServiceMap)
    77  
    78  	for k, v := range serviceMap {
    79  		newServiceMap[k] = convertServiceKeysToStrings(v)
    80  	}
    81  
    82  	return newServiceMap
    83  }
    84  
    85  func convertServiceKeysToStrings(service RawService) RawService {
    86  	newService := make(RawService)
    87  
    88  	for k, v := range service {
    89  		newService[k] = convertKeysToStrings(v)
    90  	}
    91  
    92  	return newService
    93  }
    94  
    95  func convertKeysToStrings(item interface{}) interface{} {
    96  	switch typedDatas := item.(type) {
    97  
    98  	case map[interface{}]interface{}:
    99  		newMap := make(map[string]interface{})
   100  
   101  		for key, value := range typedDatas {
   102  			stringKey := key.(string)
   103  			newMap[stringKey] = convertKeysToStrings(value)
   104  		}
   105  		return newMap
   106  
   107  	case []interface{}:
   108  		// newArray := make([]interface{}, 0) will cause golint to complain
   109  		var newArray []interface{}
   110  		newArray = make([]interface{}, 0)
   111  
   112  		for _, value := range typedDatas {
   113  			newArray = append(newArray, convertKeysToStrings(value))
   114  		}
   115  		return newArray
   116  
   117  	default:
   118  		return item
   119  	}
   120  }
   121  
   122  var dockerConfigHints = map[string]string{
   123  	"cpu_share":   "cpu_shares",
   124  	"add_host":    "extra_hosts",
   125  	"hosts":       "extra_hosts",
   126  	"extra_host":  "extra_hosts",
   127  	"device":      "devices",
   128  	"link":        "links",
   129  	"memory_swap": "memswap_limit",
   130  	"port":        "ports",
   131  	"privilege":   "privileged",
   132  	"priviliged":  "privileged",
   133  	"privilige":   "privileged",
   134  	"volume":      "volumes",
   135  	"workdir":     "working_dir",
   136  }
   137  
   138  func unsupportedConfigMessage(key string, nextErr gojsonschema.ResultError) string {
   139  	service := serviceNameFromErrorField(nextErr.Field())
   140  
   141  	message := fmt.Sprintf("Unsupported config option for %s service: '%s'", service, key)
   142  	if val, ok := dockerConfigHints[key]; ok {
   143  		message += fmt.Sprintf(" (did you mean '%s'?)", val)
   144  	}
   145  
   146  	return message
   147  }
   148  
   149  func oneOfMessage(serviceMap RawServiceMap, schema map[string]interface{}, err, nextErr gojsonschema.ResultError) string {
   150  	switch nextErr.Type() {
   151  	case "additional_property_not_allowed":
   152  		property := nextErr.Details()["property"]
   153  
   154  		return fmt.Sprintf("contains unsupported option: '%s'", property)
   155  	case "invalid_type":
   156  		if containsTypeError(nextErr) {
   157  			expectedType := addArticle(nextErr.Details()["expected"].(string))
   158  
   159  			return fmt.Sprintf("contains %s, which is an invalid type, it should be %s", getValue(serviceMap, nextErr.Context().String()), expectedType)
   160  		}
   161  
   162  		validTypes := parseValidTypesFromSchema(schema, err.Context().String())
   163  
   164  		validTypesMsg := addArticle(strings.Join(validTypes, " or "))
   165  
   166  		return fmt.Sprintf("contains an invalid type, it should be %s", validTypesMsg)
   167  	case "unique":
   168  		contextWithDuplicates := getValue(serviceMap, nextErr.Context().String())
   169  
   170  		return fmt.Sprintf("contains non unique items, please remove duplicates from %s", contextWithDuplicates)
   171  	}
   172  
   173  	return ""
   174  }
   175  
   176  func invalidTypeMessage(service, key string, err gojsonschema.ResultError) string {
   177  	expectedTypesString := err.Details()["expected"].(string)
   178  	var expectedTypes []string
   179  
   180  	if strings.Contains(expectedTypesString, ",") {
   181  		expectedTypes = strings.Split(expectedTypesString[1:len(expectedTypesString)-1], ",")
   182  	} else {
   183  		expectedTypes = []string{expectedTypesString}
   184  	}
   185  
   186  	validTypesMsg := addArticle(strings.Join(expectedTypes, " or "))
   187  
   188  	return fmt.Sprintf("Service '%s' configuration key '%s' contains an invalid type, it should be %s.", service, key, validTypesMsg)
   189  }
   190  
   191  func validate(serviceMap RawServiceMap) error {
   192  	if err := setupSchemaLoaders(); err != nil {
   193  		return err
   194  	}
   195  
   196  	serviceMap = convertServiceMapKeysToStrings(serviceMap)
   197  
   198  	var validationErrors []string
   199  
   200  	dataLoader := gojsonschema.NewGoLoader(serviceMap)
   201  
   202  	result, err := gojsonschema.Validate(schemaLoader, dataLoader)
   203  	if err != nil {
   204  		return err
   205  	}
   206  
   207  	// gojsonschema can create extraneous "additional_property_not_allowed" errors in some cases
   208  	// If this is set, and the error is at root level, skip over that error
   209  	skipRootAdditionalPropertyError := false
   210  
   211  	if !result.Valid() {
   212  		for i := 0; i < len(result.Errors()); i++ {
   213  			err := result.Errors()[i]
   214  
   215  			if skipRootAdditionalPropertyError && err.Type() == "additional_property_not_allowed" && err.Context().String() == "(root)" {
   216  				skipRootAdditionalPropertyError = false
   217  				continue
   218  			}
   219  
   220  			if err.Context().String() == "(root)" {
   221  				switch err.Type() {
   222  				case "additional_property_not_allowed":
   223  					validationErrors = append(validationErrors, fmt.Sprintf("Invalid service name '%s' - only [a-zA-Z0-9\\._\\-] characters are allowed", err.Field()))
   224  				default:
   225  					validationErrors = append(validationErrors, err.Description())
   226  				}
   227  			} else {
   228  				skipRootAdditionalPropertyError = true
   229  
   230  				serviceName := serviceNameFromErrorField(err.Field())
   231  				key := keyNameFromErrorField(err.Field())
   232  
   233  				switch err.Type() {
   234  				case "additional_property_not_allowed":
   235  					validationErrors = append(validationErrors, unsupportedConfigMessage(key, result.Errors()[i+1]))
   236  				case "number_one_of":
   237  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' %s", serviceName, key, oneOfMessage(serviceMap, schema, err, result.Errors()[i+1])))
   238  
   239  					// Next error handled in oneOfMessage, skip over it
   240  					i++
   241  				case "invalid_type":
   242  					validationErrors = append(validationErrors, invalidTypeMessage(serviceName, key, err))
   243  				case "required":
   244  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' option '%s' is invalid, %s", serviceName, key, err.Description()))
   245  				case "missing_dependency":
   246  					dependency := err.Details()["dependency"].(string)
   247  					validationErrors = append(validationErrors, fmt.Sprintf("Invalid configuration for '%s' service: dependency '%s' is not satisfied", serviceName, dependency))
   248  				case "unique":
   249  					contextWithDuplicates := getValue(serviceMap, err.Context().String())
   250  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' value %s has non-unique elements", serviceName, key, contextWithDuplicates))
   251  				default:
   252  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key %s value %s", serviceName, key, err.Description()))
   253  				}
   254  			}
   255  		}
   256  
   257  		return fmt.Errorf(strings.Join(validationErrors, "\n"))
   258  	}
   259  
   260  	return nil
   261  }
   262  
   263  func validateServiceConstraints(service RawService, serviceName string) error {
   264  	if err := setupSchemaLoaders(); err != nil {
   265  		return err
   266  	}
   267  
   268  	service = convertServiceKeysToStrings(service)
   269  
   270  	var validationErrors []string
   271  
   272  	dataLoader := gojsonschema.NewGoLoader(service)
   273  
   274  	result, err := gojsonschema.Validate(constraintSchemaLoader, dataLoader)
   275  	if err != nil {
   276  		return err
   277  	}
   278  
   279  	if !result.Valid() {
   280  		for _, err := range result.Errors() {
   281  			if err.Type() == "number_any_of" {
   282  				_, containsImage := service["image"]
   283  				_, containsBuild := service["build"]
   284  				_, containsDockerfile := service["dockerfile"]
   285  
   286  				if containsImage && containsBuild {
   287  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has both an image and build path specified. A service can either be built to image or use an existing image, not both.", serviceName))
   288  				} else if !containsImage && !containsBuild {
   289  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has neither an image nor a build path specified. Exactly one must be provided.", serviceName))
   290  				} else if containsImage && containsDockerfile {
   291  					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has both an image and alternate Dockerfile. A service can either be built to image or use an existing image, not both.", serviceName))
   292  				}
   293  			}
   294  		}
   295  
   296  		return fmt.Errorf(strings.Join(validationErrors, "\n"))
   297  	}
   298  
   299  	return nil
   300  }