github.com/connyay/libcompose@v0.4.0/config/validation.go (about)

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