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 }