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  }