github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/cloud/validations.go (about) 1 // Copyright 2017 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 // Package cloud provides functionality to parse information 5 // describing clouds, including regions, supported auth types etc. 6 7 package cloud 8 9 import ( 10 "fmt" 11 "reflect" 12 "strings" 13 14 "github.com/juju/errors" 15 "github.com/juju/gojsonschema" 16 "gopkg.in/yaml.v2" 17 ) 18 19 // ValidationWarning are JSON schema validation errors used to warn users about 20 // potential schema violations 21 type ValidationWarning struct { 22 Messages []string 23 } 24 25 func (e *ValidationWarning) Error() string { 26 str := "" 27 for _, msg := range e.Messages { 28 str = fmt.Sprintf("%s\n%s", str, msg) 29 } 30 31 return str 32 } 33 34 var cloudSetSchema = map[string]interface{}{ 35 "type": "object", 36 "properties": map[string]interface{}{ 37 "clouds": map[string]interface{}{ 38 "type": "object", 39 "additionalProperties": cloudSchema, 40 }, 41 }, 42 "additionalProperties": false, 43 } 44 45 var cloudSchema = map[string]interface{}{ 46 "type": "object", 47 "properties": map[string]interface{}{ 48 "name": map[string]interface{}{"type": "string"}, 49 "type": map[string]interface{}{"type": "string"}, 50 "description": map[string]interface{}{"type": "string"}, 51 "auth-types": map[string]interface{}{ 52 "type": "array", 53 "items": map[string]interface{}{"type": "string"}, 54 }, 55 "host-cloud-region": map[string]interface{}{"type": "string"}, 56 "endpoint": map[string]interface{}{"type": "string"}, 57 "identity-endpoint": map[string]interface{}{"type": "string"}, 58 "storage-endpoint": map[string]interface{}{"type": "string"}, 59 "config": map[string]interface{}{"type": "object"}, 60 "regions": regionsSchema, 61 "region-config": map[string]interface{}{"type": "object"}, 62 "ca-certificates": map[string]interface{}{ 63 "type": "array", 64 "items": map[string]interface{}{"type": "string"}, 65 }, 66 }, 67 "additionalProperties": false, 68 } 69 70 var regionsSchema = map[string]interface{}{ 71 "type": "object", 72 "additionalProperties": map[string]interface{}{ 73 "type": "object", 74 "properties": map[string]interface{}{ 75 "endpoint": map[string]interface{}{"type": "string"}, 76 "identity-endpoint": map[string]interface{}{"type": "string"}, 77 "storage-endpoint": map[string]interface{}{"type": "string"}, 78 }, 79 "additionalProperties": false, 80 }, 81 } 82 83 // ValidateCloudSet reports any erroneous properties found in cloud metadata 84 // YAML. If there are no erroneous properties, then ValidateCloudSet returns nil 85 // otherwise it return an error listing all erroneous properties and possible 86 // suggestion. 87 func ValidateCloudSet(data []byte) error { 88 return validateCloud(data, &cloudSetSchema) 89 } 90 91 // ValidateOneCloud is like ValidateCloudSet but validates the metadata for only 92 // one cloud and not multiple. 93 func ValidateOneCloud(data []byte) error { 94 return validateCloud(data, &cloudSchema) 95 } 96 97 func validateCloud(data []byte, jsonSchema *map[string]interface{}) error { 98 var body interface{} 99 if err := yaml.Unmarshal(data, &body); err != nil { 100 return errors.Annotate(err, "cannot unmarshal yaml cloud metadata") 101 } 102 103 jsonBody := yamlToJSON(body) 104 invalidKeys, err := validateCloudMetaData(jsonBody, jsonSchema) 105 if err != nil { 106 return errors.Annotate(err, "cannot validate yaml cloud metadata") 107 } 108 109 formatKeyError := func(invalidKey, similarValidKey string) string { 110 str := fmt.Sprintf("property %s is invalid.", invalidKey) 111 if similarValidKey != "" { 112 str = fmt.Sprintf("%s Perhaps you mean %q.", str, similarValidKey) 113 } 114 return str 115 } 116 117 cloudValidationError := ValidationWarning{} 118 for k, v := range invalidKeys { 119 cloudValidationError.Messages = append(cloudValidationError.Messages, formatKeyError(k, v)) 120 } 121 122 if len(cloudValidationError.Messages) != 0 { 123 return &cloudValidationError 124 } 125 126 return nil 127 } 128 129 func cloudTags() []string { 130 keys := make(map[string]struct{}) 131 collectTags(reflect.TypeOf((*cloud)(nil)), "yaml", []string{"map[string]*cloud.region", "yaml.MapSlice"}, &keys) 132 keyList := make([]string, 0, len(keys)) 133 for k := range keys { 134 keyList = append(keyList, k) 135 } 136 137 return keyList 138 } 139 140 // collectTags returns a set of keys for a specified struct tag. If no tag is 141 // specified for a particular field of the argument struct type, then the 142 // all-lowercase field name is used as per Go tag conventions. If the tag 143 // specified is not the name a conventionally formatted go struct tag, then the 144 // results of this function are invalid. Values of invalid kinds result in no 145 // processing. 146 func collectTags(t reflect.Type, tag string, ignoreTypes []string, keys *map[string]struct{}) { 147 switch t.Kind() { 148 149 case reflect.Array, reflect.Slice, reflect.Map, reflect.Ptr: 150 collectTags(t.Elem(), tag, ignoreTypes, keys) 151 152 case reflect.Struct: 153 for i := 0; i < t.NumField(); i++ { 154 field := t.Field(i) 155 156 fieldTag := field.Tag.Get(tag) 157 var fieldTagKey string 158 159 ignoredType := false 160 for _, it := range ignoreTypes { 161 if field.Type.String() == it { 162 ignoredType = true 163 break 164 } 165 } 166 167 if fieldTag == "-" || ignoredType { 168 continue 169 } 170 171 if len(fieldTag) > 0 { 172 fieldTagKey = strings.Split(fieldTag, ",")[0] 173 } else { 174 fieldTagKey = strings.ToLower(field.Name) 175 } 176 177 (*keys)[fieldTagKey] = struct{}{} 178 collectTags(field.Type, tag, ignoreTypes, keys) 179 } 180 } 181 } 182 183 func validateCloudMetaData(body interface{}, jsonSchema *map[string]interface{}) (map[string]string, error) { 184 documentLoader := gojsonschema.NewGoLoader(body) 185 schemaLoader := gojsonschema.NewGoLoader(jsonSchema) 186 187 result, err := gojsonschema.Validate(schemaLoader, documentLoader) 188 if err != nil { 189 return nil, err 190 } 191 192 minEditingDistance := 5 193 194 validCloudProperties := cloudTags() 195 suggestionMap := map[string]string{} 196 for _, rsltErr := range result.Errors() { 197 invalidProperty := strings.Split(rsltErr.Description, " ")[2] 198 suggestionMap[invalidProperty] = "" 199 editingDistance := minEditingDistance 200 201 for _, validProperty := range validCloudProperties { 202 203 dist := distance(invalidProperty, validProperty) 204 if dist < editingDistance && dist < minEditingDistance { 205 editingDistance = dist 206 suggestionMap[invalidProperty] = validProperty 207 } 208 209 } 210 } 211 212 return suggestionMap, nil 213 } 214 215 func yamlToJSON(i interface{}) interface{} { 216 switch x := i.(type) { 217 case map[interface{}]interface{}: 218 m2 := map[string]interface{}{} 219 for k, v := range x { 220 m2[k.(string)] = yamlToJSON(v) 221 } 222 return m2 223 case []interface{}: 224 for i, v := range x { 225 x[i] = yamlToJSON(v) 226 } 227 } 228 return i 229 } 230 231 // The following "editing distance" comparator was lifted from 232 // https://github.com/arbovm/levenshtein/blob/master/levenshtein.go which has a 233 // compatible BSD license. We use it to calculate the distance between a 234 // discovered invalid yaml property and known good properties to identify 235 // suggestions. 236 func distance(str1, str2 string) int { 237 var cost, lastdiag, olddiag int 238 s1 := []rune(str1) 239 s2 := []rune(str2) 240 241 lenS1 := len(s1) 242 lenS2 := len(s2) 243 244 column := make([]int, lenS1+1) 245 246 for y := 1; y <= lenS1; y++ { 247 column[y] = y 248 } 249 250 for x := 1; x <= lenS2; x++ { 251 column[0] = x 252 lastdiag = x - 1 253 for y := 1; y <= lenS1; y++ { 254 olddiag = column[y] 255 cost = 0 256 if s1[y-1] != s2[x-1] { 257 cost = 1 258 } 259 column[y] = min( 260 column[y]+1, 261 column[y-1]+1, 262 lastdiag+cost) 263 lastdiag = olddiag 264 } 265 } 266 return column[lenS1] 267 } 268 269 func min(a, b, c int) int { 270 if a < b { 271 if a < c { 272 return a 273 } 274 } else { 275 if b < c { 276 return b 277 } 278 } 279 return c 280 }