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 }