validation.go 9.0 KB

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