diff --git a/assumes/expression.go b/assumes/expression.go new file mode 100644 index 0000000..8d92653 --- /dev/null +++ b/assumes/expression.go @@ -0,0 +1,61 @@ +// Copyright 2021 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package assumes + +import "github.com/juju/version/v2" + +var ( + _ Expression = (*FeatureExpression)(nil) + _ Expression = (*CompositeExpression)(nil) +) + +// ExpressionType represents the type of an assumes expression. +type ExpressionType string + +const ( + AnyOfExpression ExpressionType = "any-of" + AllOfExpression ExpressionType = "all-of" +) + +// Expression is an interface implemented by all expression types in this package. +type Expression interface { + Type() ExpressionType +} + +// VersionConstraint describes a constraint for required feature versions. +type VersionConstraint string + +const ( + VersionGTE VersionConstraint = ">=" + VersionLT VersionConstraint = "<" +) + +// FeatureExpression describes a feature that is required by the charm in order +// to be successfully deployed. Feature expressions may additionally specify a +// version constraint. +type FeatureExpression struct { + // The name of the feature. + Name string + + // A feature within an assumes block may optionally specify a version + // constraint. + Constraint VersionConstraint + Version *version.Number + + // The raw, unprocessed version string for serialization purposes. + rawVersion string +} + +// Type implements Expression. +func (FeatureExpression) Type() ExpressionType { return ExpressionType("feature") } + +// CompositeExpression describes a composite expression that applies some +// operator to a sub-expression list. +type CompositeExpression struct { + ExprType ExpressionType + SubExpressions []Expression +} + +// Type implements Expression. +func (expr CompositeExpression) Type() ExpressionType { return expr.ExprType } diff --git a/assumes/package_test.go b/assumes/package_test.go new file mode 100644 index 0000000..9a14561 --- /dev/null +++ b/assumes/package_test.go @@ -0,0 +1,14 @@ +// Copyright 2021 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package assumes + +import ( + stdtesting "testing" + + gc "gopkg.in/check.v1" +) + +func Test(t *stdtesting.T) { + gc.TestingT(t) +} diff --git a/assumes/parser.go b/assumes/parser.go new file mode 100644 index 0000000..c26c004 --- /dev/null +++ b/assumes/parser.go @@ -0,0 +1,323 @@ +// Copyright 2021 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package assumes + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/juju/errors" + "github.com/juju/mgo/v2/bson" + "github.com/juju/version/v2" + "gopkg.in/yaml.v2" +) + +var ( + featureWithoutVersion = regexp.MustCompile(`^[a-z][a-z0-9-]*?[a-z0-9]+$`) + featureWithVersion = regexp.MustCompile(`^([a-z][a-z0-9-]*?[a-z0-9]+)\s*?(>=|<)\s*?([\S\.]+)$`) +) + +// ExpressionTree is a wrapper for representing a (possibly nested) "assumes" +// block declaration. +type ExpressionTree struct { + Expression Expression +} + +// parseAssumesExpressionTree recursively parses an assumes expression tree +// and returns back an Expression instance for it. +// +// The root of the expression tree consists of a list of (potentially nested) +// assumes expressions that form an implicit All-Of composite expression. +// +// For example: +// assumes: +// - foo +// - bar >= 1.42 +// - any-of: ... (nested expr) +// - all-of: ... (nested expr) +func parseAssumesExpressionTree(rootExprList []interface{}) (Expression, error) { + var ( + rootExpr = CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: make([]Expression, len(rootExprList)), + } + err error + ) + + for i, exprDecl := range rootExprList { + if rootExpr.SubExpressions[i], err = parseAssumesExpr(exprDecl); err != nil { + return nil, errors.Annotatef(err, `parsing expression %d in top level "assumes" block`, i+1) + } + } + + return rootExpr, nil +} + +// parseAssumesExpr returns an Expression instance that corresponds to the +// provided expression declaration. As per the assumes spec, the parser +// supports the following expression types: +// +// 1) feature request expression with optional version constraint (e.g. foo < 1) +// 2) any-of composite expression +// 3) all-of composite expression +func parseAssumesExpr(exprDecl interface{}) (Expression, error) { + // Is it a composite expression? + if exprAsMap, isMap := exprDecl.(map[interface{}]interface{}); isMap { + coercedMap := make(map[string]interface{}) + for key, val := range exprAsMap { + keyStr, ok := key.(string) + if !ok { + return nil, errors.New(`malformed composite expression`) + } + coercedMap[keyStr] = val + } + return parseCompositeExpr(coercedMap) + } else if exprAsMap, isMap := exprDecl.(bson.M); isMap { + coercedMap := make(map[string]interface{}) + for key, val := range exprAsMap { + coercedMap[key] = val + } + return parseCompositeExpr(coercedMap) + } else if exprAsMap, isMap := exprDecl.(map[string]interface{}); isMap { + return parseCompositeExpr(exprAsMap) + } + + // Is it a feature request expression? + if exprAsString, isString := exprDecl.(string); isString { + return parseFeatureExpr(exprAsString) + } + + return nil, errors.New(`expected a feature, "any-of" or "all-of" expression`) +} + +// parseCompositeExpr extracts and returns a CompositeExpression from the +// provided expression declaration. +// +// The EBNF grammar for a composite expression is: +// +// composite-expr-decl: ("any-of"|"all-of") expr-decl-list +// +// expr-decl-list: expr-decl+ +// +// expr-decl: feature-expr-decl | +// composite-expr-decl +// +// The function expects a map with either a "any-of" or "all-of" key and +// a value that is a slice of sub-expressions. +func parseCompositeExpr(exprDecl map[string]interface{}) (CompositeExpression, error) { + if len(exprDecl) != 1 { + return CompositeExpression{}, errors.New("malformed composite expression") + } + + var ( + compositeExpr CompositeExpression + subExprDecls interface{} + err error + ) + + if subExprDecls = exprDecl["any-of"]; subExprDecls != nil { + compositeExpr.ExprType = AnyOfExpression + } else if subExprDecls = exprDecl["all-of"]; subExprDecls != nil { + compositeExpr.ExprType = AllOfExpression + } else { + return CompositeExpression{}, errors.New(`malformed composite expression; expected an "any-of" or "all-of" block`) + } + + subExprDeclList, isList := subExprDecls.([]interface{}) + if !isList { + return CompositeExpression{}, errors.Errorf(`malformed %q expression; expected a list of sub-expressions`, string(compositeExpr.ExprType)) + } + + compositeExpr.SubExpressions = make([]Expression, len(subExprDeclList)) + for i, subExprDecl := range subExprDeclList { + if compositeExpr.SubExpressions[i], err = parseAssumesExpr(subExprDecl); err != nil { + return CompositeExpression{}, errors.Annotatef(err, "parsing %q expression", string(compositeExpr.ExprType)) + } + } + return compositeExpr, nil +} + +// parseFeatureExpr extracts and returns a FeatureExpression from the provided +// expression declaration. +// +// The EBNF grammar for feature expressions is: +// +// feature-expr-decl: feature-ident | +// feature-ident version-constraint version-number +// +// version-constraint: ">=" | "<" +// feature-ident: [a-z][a-z0-9-]*[a-z0-9]+ +// version-number: \d+ (‘.’ \d+ (‘.’ \d+)?)? +// +func parseFeatureExpr(exprDecl string) (FeatureExpression, error) { + exprDecl = strings.TrimSpace(exprDecl) + + // Is this a feature name without a version constraint? + if featureWithoutVersion.MatchString(exprDecl) { + return FeatureExpression{Name: exprDecl}, nil + } + + matches := featureWithVersion.FindAllStringSubmatch(exprDecl, 1) + if len(matches) == 1 { + featName, constraint, versionStr := matches[0][1], matches[0][2], matches[0][3] + ver, err := version.ParseNonStrict(versionStr) + if err != nil { + return FeatureExpression{}, errors.Annotatef(err, "malformed feature expression %q", exprDecl) + } + + return FeatureExpression{ + Name: featName, + Constraint: VersionConstraint(constraint), + Version: &ver, + rawVersion: versionStr, + }, nil + } + + return FeatureExpression{}, errors.Errorf("malformed feature expression %q", exprDecl) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (tree *ExpressionTree) UnmarshalYAML(unmarshalFn func(interface{}) error) error { + var exprTree []interface{} + if err := unmarshalFn(&exprTree); err != nil { + if _, isTypeErr := err.(*yaml.TypeError); isTypeErr { + return errors.New(`malformed "assumes" block; expected an expression list`) + } + return errors.Annotate(err, "decoding assumes block") + } + + expr, err := parseAssumesExpressionTree(exprTree) + if err != nil { + return errors.Trace(err) + } + tree.Expression = expr + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (tree *ExpressionTree) UnmarshalJSON(data []byte) error { + var exprTree []interface{} + if err := json.Unmarshal(data, &exprTree); err != nil { + return errors.Annotate(err, "decoding assumes block") + } + + expr, err := parseAssumesExpressionTree(exprTree) + if err != nil { + return errors.Trace(err) + } + tree.Expression = expr + return nil +} + +// SetBSON implements the bson.Setter interface. +func (tree *ExpressionTree) SetBSON(data bson.Raw) error { + var exprTree []interface{} + if err := data.Unmarshal(&exprTree); err != nil { + return errors.Annotate(err, "decoding assumes block") + } + + expr, err := parseAssumesExpressionTree(exprTree) + if err != nil { + return errors.Trace(err) + } + tree.Expression = expr + return nil +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (tree *ExpressionTree) MarshalYAML() (interface{}, error) { + if tree == nil || tree.Expression == nil { + return nil, nil + } + + return marshalAssumesExpressionTree(tree) +} + +// MarshalJSON implements the json.Marshaler interface. +func (tree *ExpressionTree) MarshalJSON() ([]byte, error) { + if tree == nil || tree.Expression == nil { + return nil, nil + } + + exprList, err := marshalAssumesExpressionTree(tree) + if err != nil { + return nil, errors.Trace(err) + } + return json.Marshal(exprList) +} + +// GetBSON implements the bson.Getter interface. +func (tree *ExpressionTree) GetBSON() (interface{}, error) { + if tree == nil || tree.Expression == nil { + return nil, nil + } + + exprList, err := marshalAssumesExpressionTree(tree) + if err != nil { + return nil, errors.Trace(err) + } + return exprList, nil +} + +func marshalAssumesExpressionTree(tree *ExpressionTree) (interface{}, error) { + // The root of the expression tree (top level of the assumes block) is + // always an implicit "any-of". We need to marshal it into a map and + // extract the expression list. + root, err := marshalExpr(tree.Expression) + if err != nil { + return nil, err + } + + rootMap, ok := root.(map[string]interface{}) + if !ok { + return nil, errors.New(`unexpected serialized output for top-level "assumes" block`) + } + + exprList, ok := rootMap[string(AllOfExpression)] + if !ok { + return nil, errors.New(`unexpected serialized output for top-level "assumes" block`) + } + + return exprList, nil +} + +func marshalExpr(expr Expression) (interface{}, error) { + featExpr, ok := expr.(FeatureExpression) + if ok { + if featExpr.Version == nil { + return featExpr.Name, nil + } + + // If we retained the raw version use that; otherwise convert + // the parsed version to a string. + if featExpr.rawVersion != "" { + return fmt.Sprintf("%s %s %s", featExpr.Name, featExpr.Constraint, featExpr.rawVersion), nil + } + + return fmt.Sprintf("%s %s %s", featExpr.Name, featExpr.Constraint, featExpr.Version.String()), nil + } + + // This is a composite expression + compExpr, ok := expr.(CompositeExpression) + if !ok { + return nil, errors.Errorf("unexpected expression type %s", expr.Type()) + } + + var ( + exprList = make([]interface{}, len(compExpr.SubExpressions)) + err error + ) + + for i, subExpr := range compExpr.SubExpressions { + if exprList[i], err = marshalExpr(subExpr); err != nil { + return nil, err + } + } + + return map[string]interface{}{ + string(compExpr.ExprType): exprList, + }, nil +} diff --git a/assumes/parser_test.go b/assumes/parser_test.go new file mode 100644 index 0000000..7906068 --- /dev/null +++ b/assumes/parser_test.go @@ -0,0 +1,406 @@ +// Copyright 2011-2015 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package assumes + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/juju/mgo/v2/bson" + jc "github.com/juju/testing/checkers" + "github.com/juju/version/v2" + gc "gopkg.in/check.v1" + "gopkg.in/yaml.v2" +) + +type ParserSuite struct{} + +var _ = gc.Suite(&ParserSuite{}) + +func (s *ParserSuite) TestNestedExpressionUnmarshalingFromYAML(c *gc.C) { + payload := ` +assumes: + - chips + - any-of: + - guacamole + - salsa + - any-of: + - good-weather + - great-music + - all-of: + - table + - lazy-suzan +`[1:] + + dst := struct { + Assumes *ExpressionTree `yaml:"assumes,omitempty"` + }{} + err := yaml.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + exp := CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "chips"}, + CompositeExpression{ + ExprType: AnyOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "guacamole"}, + FeatureExpression{Name: "salsa"}, + CompositeExpression{ + ExprType: AnyOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "good-weather"}, + FeatureExpression{Name: "great-music"}, + }, + }, + }, + }, + CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "table"}, + FeatureExpression{Name: "lazy-suzan"}, + }, + }, + }, + } + + c.Assert(dst.Assumes.Expression, gc.DeepEquals, exp) +} + +func (s *ParserSuite) TestNestedExpressionUnmarshalingFromJSON(c *gc.C) { + payload := ` +{ + "assumes": [ + "chips", + { + "any-of": [ + "guacamole", + "salsa", + { + "any-of": [ + "good-weather", + "great-music" + ] + } + ] + }, + { + "all-of": [ + "table", + "lazy-suzan" + ] + } + ] +} +`[1:] + + dst := struct { + Assumes *ExpressionTree `json:"assumes,omitempty"` + }{} + err := json.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + exp := CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "chips"}, + CompositeExpression{ + ExprType: AnyOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "guacamole"}, + FeatureExpression{Name: "salsa"}, + CompositeExpression{ + ExprType: AnyOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "good-weather"}, + FeatureExpression{Name: "great-music"}, + }, + }, + }, + }, + CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "table"}, + FeatureExpression{Name: "lazy-suzan"}, + }, + }, + }, + } + + c.Assert(dst.Assumes.Expression, gc.DeepEquals, exp) +} + +func (s *ParserSuite) TestVersionlessFeatureExprUnmarshalingFromYAML(c *gc.C) { + payload := ` +assumes: + - chips +`[1:] + + dst := struct { + Assumes *ExpressionTree `yaml:"assumes,omitempty"` + }{} + err := yaml.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + exp := CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{Name: "chips"}, + }, + } + + c.Assert(dst.Assumes.Expression, gc.DeepEquals, exp) +} + +func (s *ParserSuite) TestVersionedFeatureExprUnmarshaling(c *gc.C) { + payload := ` +assumes: # test various combinations of whitespace and version formats + - chips >= 2000.1.2 + - chips<2042.3.4 + - k8s-api >= 1.8 + - k8s-api < 42 +`[1:] + + dst := struct { + Assumes *ExpressionTree `yaml:"assumes,omitempty"` + }{} + err := yaml.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + exp := CompositeExpression{ + ExprType: AllOfExpression, + SubExpressions: []Expression{ + FeatureExpression{ + Name: "chips", + Constraint: VersionGTE, + Version: &version.Number{ + Major: 2000, + Minor: 1, + Patch: 2, + }, + rawVersion: "2000.1.2", + }, + FeatureExpression{ + Name: "chips", + Constraint: VersionLT, + Version: &version.Number{ + Major: 2042, + Minor: 3, + Patch: 4, + }, + rawVersion: "2042.3.4", + }, + FeatureExpression{ + Name: "k8s-api", + Constraint: VersionGTE, + Version: &version.Number{ + Major: 1, + Minor: 8, + }, + rawVersion: "1.8", + }, + FeatureExpression{ + Name: "k8s-api", + Constraint: VersionLT, + Version: &version.Number{ + Major: 42, + }, + rawVersion: "42", + }, + }, + } + + c.Assert(dst.Assumes.Expression, gc.DeepEquals, exp) +} + +func (s *ParserSuite) TestMalformedCompositeExpression(c *gc.C) { + payload := ` +assumes: + - root: + - access +`[1:] + + dst := struct { + Assumes *ExpressionTree `yaml:"assumes,omitempty"` + }{} + err := yaml.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, gc.ErrorMatches, `.*expected an "any-of" or "all-of" block.*`) +} + +func (s *ParserSuite) TestFeatureExprParser(c *gc.C) { + specs := []struct { + descr string + in string + expErr string + }{ + { + descr: "feature without version", + in: "k8s", + }, + { + descr: "feature with GTE version constraint", + in: "juju >= 1.2.3", + }, + { + descr: "feature with LT version constraint", + in: "juju < 1.2.3", + }, + { + descr: "feature with incorrect prefix", + in: "0day", + expErr: ".*malformed.*", + }, + { + descr: "feature with incorrect prefix (II)", + in: "-day", + expErr: ".*malformed.*", + }, + { + descr: "feature with incorrect suffix", + in: "a-day-", + expErr: ".*malformed.*", + }, + { + descr: "feature with bogus version constraint", + in: "popcorn = 1.0.0", + expErr: ".*malformed.*", + }, + { + descr: "feature with only major version component", + in: "popcorn >= 1", + }, + { + descr: "feature with only major/minor version component", + in: "popcorn >= 1.2", + }, + } + + for specIdx, spec := range specs { + c.Logf("%d. %s", specIdx, spec.descr) + _, err := parseFeatureExpr(spec.in) + if spec.expErr == "" { + c.Assert(err, jc.ErrorIsNil) + } else { + c.Assert(err, gc.ErrorMatches, spec.expErr) + } + } +} + +func (s *ParserSuite) TestMarshalToYAML(c *gc.C) { + payload := ` +assumes: +- chips +- any-of: + - guacamole + - salsa + - any-of: + - good-weather + - great-music +- all-of: + - table + - lazy-suzan +`[1:] + + dst := struct { + Assumes *ExpressionTree `yaml:"assumes,omitempty"` + }{} + err := yaml.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + var buf bytes.Buffer + err = yaml.NewEncoder(&buf).Encode(dst) + c.Assert(err, jc.ErrorIsNil) + c.Assert(buf.String(), gc.Equals, payload, gc.Commentf("serialized assumes block not matching original input")) +} + +func (s *ParserSuite) TestMarshalToJSON(c *gc.C) { + payload := ` +{ + "assumes": [ + "chips", + { + "any-of": [ + "guacamole", + "salsa", + { + "any-of": [ + "good-weather", + "great-music" + ] + } + ] + }, + { + "all-of": [ + "table", + "lazy-suzan" + ] + } + ] +} +`[1:] + + dst := struct { + Assumes *ExpressionTree `json:"assumes,omitempty"` + }{} + err := json.NewDecoder(strings.NewReader(payload)).Decode(&dst) + c.Assert(err, jc.ErrorIsNil) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err = enc.Encode(dst) + c.Assert(err, jc.ErrorIsNil) + c.Assert(buf.String(), gc.Equals, payload, gc.Commentf("serialized assumes block not matching original input")) +} + +func (s *ParserSuite) TestMarshalToBSONAndBack(c *gc.C) { + payload := ` +{ + "assumes": [ + "chips", + { + "any-of": [ + "guacamole", + "salsa", + { + "any-of": [ + "good-weather", + "great-music" + ] + } + ] + }, + { + "all-of": [ + "table", + "lazy-suzan" + ] + } + ] +} +`[1:] + + src := struct { + Assumes *ExpressionTree `json:"assumes,omitempty" bson:"assumes,omitempty"` + }{} + err := json.NewDecoder(strings.NewReader(payload)).Decode(&src) + c.Assert(err, jc.ErrorIsNil) + + // Marshal to bson + marshaledBSON, err := bson.Marshal(src) + c.Assert(err, jc.ErrorIsNil) + + // Unmarshal expression tree + dst := struct { + Assumes *ExpressionTree `json:"assumes,omitempty" bson:"assumes,omitempty"` + }{} + err = bson.Unmarshal(marshaledBSON, &dst) + c.Assert(err, jc.ErrorIsNil) + c.Assert(src, gc.DeepEquals, dst, gc.Commentf("serialized assumes block not matching original input")) +} diff --git a/go.mod b/go.mod index bbfef87..4095688 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/juju/schema v0.0.0-20160301111646-1e25943f8c6f github.com/juju/testing v0.0.0-20210302031854-2c7ee8570c07 github.com/juju/utils/v2 v2.0.0-20200923005554-4646bfea2ef1 - github.com/juju/version/v2 v2.0.0-20210319015800-dcfac8f4f057 + github.com/juju/version/v2 v2.0.0-20211007103408-2e8da085dc23 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/stretchr/testify v1.6.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c diff --git a/go.sum b/go.sum index f3ae967..7d1430b 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/juju/version v0.0.0-20161031051906-1f41e27e54f2/go.mod h1:kE8gK5X0CIm github.com/juju/version v0.0.0-20180108022336-b64dbd566305/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6 h1:nrqc9b4YKpKV4lPI3GPPFbo5FUuxkWxgZE2Z8O4lgaw= github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= -github.com/juju/version/v2 v2.0.0-20210319015800-dcfac8f4f057 h1:BT3bXGYas64ISk/NRmGcHWe2n9OLLmEfAM7LUrlDnww= -github.com/juju/version/v2 v2.0.0-20210319015800-dcfac8f4f057/go.mod h1:Ljlbryh9sYaUSGXucslAEDf0A2XUSGvDbHJgW8ps6nc= +github.com/juju/version/v2 v2.0.0-20211007103408-2e8da085dc23 h1:wtEPbidt1VyHlb8RSztU6ySQj29FLsOQiI9XiJhXDM4= +github.com/juju/version/v2 v2.0.0-20211007103408-2e8da085dc23/go.mod h1:Ljlbryh9sYaUSGXucslAEDf0A2XUSGvDbHJgW8ps6nc= github.com/julienschmidt/httprouter v1.1.1-0.20151013225520-77a895ad01eb/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= diff --git a/meta.go b/meta.go index a5d9cad..496c519 100644 --- a/meta.go +++ b/meta.go @@ -22,6 +22,7 @@ import ( "github.com/juju/version/v2" "gopkg.in/yaml.v2" + "github.com/juju/charm/v9/assumes" "github.com/juju/charm/v9/hooks" "github.com/juju/charm/v9/resource" ) @@ -277,8 +278,8 @@ type Meta struct { MinJujuVersion version.Number `bson:"min-juju-version,omitempty" json:"min-juju-version,omitempty"` // v2 - Containers map[string]Container `bson:"containers,omitempty" json:"containers,omitempty" yaml:"containers,omitempty"` - Assumes []string `bson:"assumes,omitempty" json:"assumes,omitempty" yaml:"assumes,omitempty"` + Containers map[string]Container `bson:"containers,omitempty" json:"containers,omitempty" yaml:"containers,omitempty"` + Assumes *assumes.ExpressionTree `bson:"assumes,omitempty" json:"assumes,omitempty" yaml:"assumes,omitempty"` } // Container specifies the possible systems it supports and mounts it wants. @@ -512,6 +513,18 @@ func (meta *Meta) UnmarshalYAML(f func(interface{}) error) error { } *meta = *meta1 + + // Assumes blocks have their own dedicated parser so we need to invoke + // it here and attach the resulting expression tree (if any) to the + // metadata + var assumesBlock = struct { + Assumes *assumes.ExpressionTree `yaml:"assumes"` + }{} + if err := f(&assumesBlock); err != nil { + return err + } + meta.Assumes = assumesBlock.Assumes + return nil } @@ -559,7 +572,6 @@ func parseMeta(m map[string]interface{}) (*Meta, error) { } // v2 parsing - meta.Assumes = parseStringList(m["assumes"]) meta.Containers, err = parseContainers(m["containers"], meta.Resources, meta.Storage) if err != nil { return nil, errors.Annotatef(err, "parsing containers") @@ -595,7 +607,7 @@ func (m Meta) MarshalYAML() (interface{}, error) { MinJujuVersion string `yaml:"min-juju-version,omitempty"` Resources map[string]marshaledResourceMeta `yaml:"resources,omitempty"` Containers map[string]marshaledContainer `yaml:"containers,omitempty"` - Assumes []string `yaml:"assumes,omitempty"` + Assumes *assumes.ExpressionTree `yaml:"assumes,omitempty"` }{ Name: m.Name, Summary: m.Summary, @@ -857,7 +869,7 @@ func (m Meta) Check(format Format, reasons ...FormatSelectionReason) error { } func (m Meta) checkV1(reasons []FormatSelectionReason) error { - if len(m.Assumes) != 0 { + if m.Assumes != nil { return errors.NotValidf("assumes in metadata v1") } if len(m.Containers) != 0 { @@ -1364,7 +1376,7 @@ var charmSchema = schema.FieldMap( "resources": schema.StringMap(resourceSchema), "terms": schema.List(schema.String()), "min-juju-version": schema.String(), - "assumes": schema.List(schema.String()), + "assumes": schema.List(schema.Any()), "containers": schema.StringMap(containerSchema), }, schema.Defaults{ diff --git a/meta_test.go b/meta_test.go index 3bde6de..eae661e 100644 --- a/meta_test.go +++ b/meta_test.go @@ -18,6 +18,7 @@ import ( "gopkg.in/yaml.v2" "github.com/juju/charm/v9" + "github.com/juju/charm/v9/assumes" "github.com/juju/charm/v9/resource" ) @@ -1020,6 +1021,24 @@ resources: filename: 'y.tgz' type: file `, +}, { + about: "minimal charm with nested assumes block", + yaml: ` +name: minimal-with-assumes +description: d +summary: s +assumes: +- chips +- any-of: + - guacamole + - salsa + - any-of: + - good-weather + - great-music +- all-of: + - table + - lazy-suzan +`, }} func (s *MetaSuite) TestYAMLMarshal(c *gc.C) { @@ -1910,7 +1929,7 @@ func (FormatMetaSuite) TestCheckV1(c *gc.C) { func (FormatMetaSuite) TestCheckV1WithAssumes(c *gc.C) { meta := charm.Meta{ - Assumes: []string{"pebble"}, + Assumes: new(assumes.ExpressionTree), } err := meta.Check(charm.FormatV1) c.Assert(err, gc.ErrorMatches, `assumes in metadata v1 not valid`)