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  }