gopkg.in/juju/charm.v6-unstable@v6.0.0-20171026192109-50d0c219b496/actions.go (about) 1 // Copyright 2011-2015 Canonical Ltd. 2 // Licensed under the LGPLv3, see LICENCE file for details. 3 4 package charm 5 6 import ( 7 "fmt" 8 "io" 9 "io/ioutil" 10 "regexp" 11 "strings" 12 13 "github.com/juju/errors" 14 gjs "github.com/juju/gojsonschema" 15 "gopkg.in/yaml.v2" 16 ) 17 18 var prohibitedSchemaKeys = map[string]bool{"$ref": true, "$schema": true} 19 20 var actionNameRule = regexp.MustCompile("^[a-z](?:[a-z-]*[a-z])?$") 21 22 // Actions defines the available actions for the charm. Additional params 23 // may be added as metadata at a future time (e.g. version.) 24 type Actions struct { 25 ActionSpecs map[string]ActionSpec `yaml:"actions,omitempty" bson:",omitempty"` 26 } 27 28 // Build this out further if it becomes necessary. 29 func NewActions() *Actions { 30 return &Actions{} 31 } 32 33 // ActionSpec is a definition of the parameters and traits of an Action. 34 // The Params map is expected to conform to JSON-Schema Draft 4 as defined at 35 // http://json-schema.org/draft-04/schema# (see http://json-schema.org/latest/json-schema-core.html) 36 type ActionSpec struct { 37 Description string 38 Params map[string]interface{} 39 } 40 41 // ValidateParams validates the passed params map against the given ActionSpec 42 // and returns any error encountered. 43 // Usage: 44 // err := ch.Actions().ActionSpecs["snapshot"].ValidateParams(someMap) 45 func (spec *ActionSpec) ValidateParams(params map[string]interface{}) error { 46 // Load the schema from the Charm. 47 specLoader := gjs.NewGoLoader(spec.Params) 48 schema, err := gjs.NewSchema(specLoader) 49 if err != nil { 50 return err 51 } 52 53 // Load the params as a document to validate. 54 // If an empty map was passed, we need an empty map to validate against. 55 p := map[string]interface{}{} 56 if len(params) > 0 { 57 p = params 58 } 59 docLoader := gjs.NewGoLoader(p) 60 results, err := schema.Validate(docLoader) 61 if err != nil { 62 return err 63 } 64 if results.Valid() { 65 return nil 66 } 67 68 // Handle any errors generated by the Validate(). 69 var errorStrings []string 70 for _, validationError := range results.Errors() { 71 errorStrings = append(errorStrings, validationError.String()) 72 } 73 return errors.Errorf("validation failed: %s", strings.Join(errorStrings, "; ")) 74 } 75 76 // InsertDefaults inserts the schema's default values in target using 77 // github.com/juju/gojsonschema. If a nil target is received, an empty map 78 // will be created as the target. The target is then mutated to include the 79 // defaults. 80 // 81 // The returned map will be the transformed or created target map. 82 func (spec *ActionSpec) InsertDefaults(target map[string]interface{}) (map[string]interface{}, error) { 83 specLoader := gjs.NewGoLoader(spec.Params) 84 schema, err := gjs.NewSchema(specLoader) 85 if err != nil { 86 return target, err 87 } 88 89 return schema.InsertDefaults(target) 90 } 91 92 // ReadActions builds an Actions spec from a charm's actions.yaml. 93 func ReadActionsYaml(r io.Reader) (*Actions, error) { 94 data, err := ioutil.ReadAll(r) 95 if err != nil { 96 return nil, err 97 } 98 99 result := &Actions{ 100 ActionSpecs: map[string]ActionSpec{}, 101 } 102 103 var unmarshaledActions map[string]map[string]interface{} 104 if err := yaml.Unmarshal(data, &unmarshaledActions); err != nil { 105 return nil, err 106 } 107 108 for name, actionSpec := range unmarshaledActions { 109 if valid := actionNameRule.MatchString(name); !valid { 110 return nil, fmt.Errorf("bad action name %s", name) 111 } 112 if reserved, reason := reservedName(name); reserved { 113 return nil, fmt.Errorf( 114 "cannot use action name %s: %s", 115 name, reason, 116 ) 117 } 118 119 desc := "No description" 120 thisActionSchema := map[string]interface{}{ 121 "description": desc, 122 "type": "object", 123 "title": name, 124 "properties": map[string]interface{}{}, 125 } 126 127 for key, value := range actionSpec { 128 switch key { 129 case "description": 130 // These fields must be strings. 131 typed, ok := value.(string) 132 if !ok { 133 return nil, errors.Errorf("value for schema key %q must be a string", key) 134 } 135 thisActionSchema[key] = typed 136 desc = typed 137 case "title": 138 // These fields must be strings. 139 typed, ok := value.(string) 140 if !ok { 141 return nil, errors.Errorf("value for schema key %q must be a string", key) 142 } 143 thisActionSchema[key] = typed 144 case "required": 145 typed, ok := value.([]interface{}) 146 if !ok { 147 return nil, errors.Errorf("value for schema key %q must be a YAML list", key) 148 } 149 thisActionSchema[key] = typed 150 case "params": 151 // Clean any map[interface{}]interface{}s out so they don't 152 // cause problems with BSON serialization later. 153 cleansedParams, err := cleanse(value) 154 if err != nil { 155 return nil, err 156 } 157 158 // JSON-Schema must be a map 159 typed, ok := cleansedParams.(map[string]interface{}) 160 if !ok { 161 return nil, errors.New("params failed to parse as a map") 162 } 163 thisActionSchema["properties"] = typed 164 default: 165 // In case this has nested maps, we must clean them out. 166 typed, err := cleanse(value) 167 if err != nil { 168 return nil, err 169 } 170 thisActionSchema[key] = typed 171 } 172 } 173 174 // Make sure the new Params doc conforms to JSON-Schema 175 // Draft 4 (http://json-schema.org/latest/json-schema-core.html) 176 schemaLoader := gjs.NewGoLoader(thisActionSchema) 177 _, err := gjs.NewSchema(schemaLoader) 178 if err != nil { 179 return nil, errors.Annotatef(err, "invalid params schema for action schema %s", name) 180 } 181 182 // Now assign the resulting schema to the final entry for the result. 183 result.ActionSpecs[name] = ActionSpec{ 184 Description: desc, 185 Params: thisActionSchema, 186 } 187 } 188 return result, nil 189 } 190 191 // cleanse rejects schemas containing references or maps keyed with non- 192 // strings, and coerces acceptable maps to contain only maps with string keys. 193 func cleanse(input interface{}) (interface{}, error) { 194 switch typedInput := input.(type) { 195 196 // In this case, recurse in. 197 case map[string]interface{}: 198 newMap := make(map[string]interface{}) 199 for key, value := range typedInput { 200 201 if prohibitedSchemaKeys[key] { 202 return nil, fmt.Errorf("schema key %q not compatible with this version of juju", key) 203 } 204 205 newValue, err := cleanse(value) 206 if err != nil { 207 return nil, err 208 } 209 newMap[key] = newValue 210 } 211 return newMap, nil 212 213 // Coerce keys to strings and error out if there's a problem; then recurse. 214 case map[interface{}]interface{}: 215 newMap := make(map[string]interface{}) 216 for key, value := range typedInput { 217 typedKey, ok := key.(string) 218 if !ok { 219 return nil, errors.New("map keyed with non-string value") 220 } 221 newMap[typedKey] = value 222 } 223 return cleanse(newMap) 224 225 // Recurse 226 case []interface{}: 227 newSlice := make([]interface{}, 0) 228 for _, sliceValue := range typedInput { 229 newSliceValue, err := cleanse(sliceValue) 230 if err != nil { 231 return nil, errors.New("map keyed with non-string value") 232 } 233 newSlice = append(newSlice, newSliceValue) 234 } 235 return newSlice, nil 236 237 // Other kinds of values are OK. 238 default: 239 return input, nil 240 } 241 } 242 243 // recurseMapOnKeys returns the value of a map keyed recursively by the 244 // strings given in "keys". Thus, recurseMapOnKeys({a,b}, {a:{b:{c:d}}}) 245 // would return {c:d}. 246 func recurseMapOnKeys(keys []string, params map[string]interface{}) (interface{}, bool) { 247 key, rest := keys[0], keys[1:] 248 answer, ok := params[key] 249 250 // If we're out of keys, we have our answer. 251 if len(rest) == 0 { 252 return answer, ok 253 } 254 255 // If we're not out of keys, but we tried a key that wasn't in the 256 // map, there's no answer. 257 if !ok { 258 return nil, false 259 } 260 261 switch typed := answer.(type) { 262 // If our value is a map[s]i{}, we can keep recursing. 263 case map[string]interface{}: 264 return recurseMapOnKeys(keys[1:], typed) 265 // If it's a map[i{}]i{}, we need to check whether it's a map[s]i{}. 266 case map[interface{}]interface{}: 267 m := make(map[string]interface{}) 268 for k, v := range typed { 269 if tK, ok := k.(string); ok { 270 m[tK] = v 271 } else { 272 // If it's not, we don't have something we 273 // can work with. 274 return nil, false 275 } 276 } 277 // If it is, recurse into it. 278 return recurseMapOnKeys(keys[1:], m) 279 280 // Otherwise, we're trying to recurse into something we don't know 281 // how to deal with, so our answer is that we don't have an answer. 282 default: 283 return nil, false 284 } 285 }