github.com/docker/libcompose@v0.4.1-0.20210616120443-2a046c0bdbf2/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 serviceMap = convertServiceMapKeysToStrings(serviceMap) 161 162 dataLoader := gojsonschema.NewGoLoader(serviceMap) 163 164 result, err := gojsonschema.Validate(schemaLoaderV1, dataLoader) 165 if err != nil { 166 return err 167 } 168 169 return generateErrorMessages(serviceMap, schemaV1, result) 170 } 171 172 func validateV2(serviceMap RawServiceMap) error { 173 serviceMap = convertServiceMapKeysToStrings(serviceMap) 174 175 dataLoader := gojsonschema.NewGoLoader(serviceMap) 176 177 result, err := gojsonschema.Validate(schemaLoaderV2, dataLoader) 178 if err != nil { 179 return err 180 } 181 182 return generateErrorMessages(serviceMap, schemaV2, result) 183 } 184 185 func generateErrorMessages(serviceMap RawServiceMap, schema map[string]interface{}, result *gojsonschema.Result) error { 186 var validationErrors []string 187 188 // gojsonschema can create extraneous "additional_property_not_allowed" errors in some cases 189 // If this is set, and the error is at root level, skip over that error 190 skipRootAdditionalPropertyError := false 191 192 if !result.Valid() { 193 for i := 0; i < len(result.Errors()); i++ { 194 err := result.Errors()[i] 195 196 if skipRootAdditionalPropertyError && err.Type() == "additional_property_not_allowed" && err.Context().String() == "(root)" { 197 skipRootAdditionalPropertyError = false 198 continue 199 } 200 201 if err.Context().String() == "(root)" { 202 switch err.Type() { 203 case "additional_property_not_allowed": 204 validationErrors = append(validationErrors, fmt.Sprintf("Invalid service name '%s' - only [a-zA-Z0-9\\._\\-] characters are allowed", err.Details()["property"])) 205 default: 206 validationErrors = append(validationErrors, err.Description()) 207 } 208 } else { 209 skipRootAdditionalPropertyError = true 210 211 serviceName := serviceNameFromErrorField(err.Field()) 212 key := keyNameFromErrorField(err.Field()) 213 214 switch err.Type() { 215 case "additional_property_not_allowed": 216 validationErrors = append(validationErrors, unsupportedConfigMessage(result.Errors()[i].Details()["property"].(string), result.Errors()[i])) 217 case "number_one_of": 218 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' %s", serviceName, key, oneOfMessage(serviceMap, schema, err, result.Errors()[i+1]))) 219 220 // Next error handled in oneOfMessage, skip over it 221 i++ 222 case "invalid_type": 223 validationErrors = append(validationErrors, invalidTypeMessage(serviceName, key, err)) 224 case "required": 225 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' option '%s' is invalid, %s", serviceName, key, err.Description())) 226 case "missing_dependency": 227 dependency := err.Details()["dependency"].(string) 228 validationErrors = append(validationErrors, fmt.Sprintf("Invalid configuration for '%s' service: dependency '%s' is not satisfied", serviceName, dependency)) 229 case "unique": 230 contextWithDuplicates := getValue(serviceMap, err.Context().String()) 231 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' value %s has non-unique elements", serviceName, key, contextWithDuplicates)) 232 default: 233 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key %s value %s", serviceName, key, err.Description())) 234 } 235 } 236 } 237 238 return fmt.Errorf(strings.Join(validationErrors, "\n")) 239 } 240 241 return nil 242 } 243 244 func validateServiceConstraints(service RawService, serviceName string) error { 245 service = convertServiceKeysToStrings(service) 246 247 var validationErrors []string 248 249 dataLoader := gojsonschema.NewGoLoader(service) 250 251 result, err := gojsonschema.Validate(constraintSchemaLoaderV1, dataLoader) 252 if err != nil { 253 return err 254 } 255 256 if !result.Valid() { 257 for _, err := range result.Errors() { 258 if err.Type() == "number_any_of" { 259 _, containsImage := service["image"] 260 _, containsBuild := service["build"] 261 _, containsDockerfile := service["dockerfile"] 262 263 if containsImage && containsBuild { 264 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)) 265 } else if !containsImage && !containsBuild { 266 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has neither an image nor a build path specified. Exactly one must be provided.", serviceName)) 267 } else if containsImage && containsDockerfile { 268 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)) 269 } 270 } 271 } 272 273 return fmt.Errorf(strings.Join(validationErrors, "\n")) 274 } 275 276 return nil 277 } 278 279 func validateServiceConstraintsv2(service RawService, serviceName string) error { 280 service = convertServiceKeysToStrings(service) 281 282 var validationErrors []string 283 284 dataLoader := gojsonschema.NewGoLoader(service) 285 286 result, err := gojsonschema.Validate(constraintSchemaLoaderV2, dataLoader) 287 if err != nil { 288 return err 289 } 290 291 if !result.Valid() { 292 for _, err := range result.Errors() { 293 if err.Type() == "required" { 294 _, containsImage := service["image"] 295 _, containsBuild := service["build"] 296 297 if containsBuild || !containsImage && !containsBuild { 298 validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has neither an image nor a build context specified. At least one must be provided.", serviceName)) 299 } 300 } 301 } 302 return fmt.Errorf(strings.Join(validationErrors, "\n")) 303 } 304 305 return nil 306 }