diff --git a/jobspec/parse.go b/jobspec/parse.go index 1231b5ec059e..b7ca31c54595 100644 --- a/jobspec/parse.go +++ b/jobspec/parse.go @@ -242,6 +242,20 @@ func parseConstraints(result *[]*structs.Constraint, obj *hclobj.Object) error { m["hard"] = true } + // If "version" is provided, set the operand + // to "version" and the value to the "RTarget" + if constraint, ok := m["version"]; ok { + m["Operand"] = "version" + m["RTarget"] = constraint + } + + // If "regexp" is provided, set the operand + // to "regexp" and the value to the "RTarget" + if constraint, ok := m["regexp"]; ok { + m["Operand"] = "regexp" + m["RTarget"] = constraint + } + // Build the constraint var c structs.Constraint if err := mapstructure.WeakDecode(m, &c); err != nil { diff --git a/jobspec/parse_test.go b/jobspec/parse_test.go index dea59ec266ed..60c5f1f0946f 100644 --- a/jobspec/parse_test.go +++ b/jobspec/parse_test.go @@ -152,6 +152,46 @@ func TestParse(t *testing.T) { false, }, + { + "version-constraint.hcl", + &structs.Job{ + ID: "foo", + Name: "foo", + Priority: 50, + Region: "global", + Type: "service", + Constraints: []*structs.Constraint{ + &structs.Constraint{ + Hard: true, + LTarget: "$attr.kernel.version", + RTarget: "~> 3.2", + Operand: "version", + }, + }, + }, + false, + }, + + { + "regexp-constraint.hcl", + &structs.Job{ + ID: "foo", + Name: "foo", + Priority: 50, + Region: "global", + Type: "service", + Constraints: []*structs.Constraint{ + &structs.Constraint{ + Hard: true, + LTarget: "$attr.kernel.version", + RTarget: "[0-9.]+", + Operand: "regexp", + }, + }, + }, + false, + }, + { "specify-job.hcl", &structs.Job{ diff --git a/jobspec/test-fixtures/regexp-constraint.hcl b/jobspec/test-fixtures/regexp-constraint.hcl new file mode 100644 index 000000000000..dfdb4ce2064a --- /dev/null +++ b/jobspec/test-fixtures/regexp-constraint.hcl @@ -0,0 +1,6 @@ +job "foo" { + constraint { + attribute = "$attr.kernel.version" + regexp = "[0-9.]+" + } +} diff --git a/jobspec/test-fixtures/version-constraint.hcl b/jobspec/test-fixtures/version-constraint.hcl new file mode 100644 index 000000000000..3ba7552729db --- /dev/null +++ b/jobspec/test-fixtures/version-constraint.hcl @@ -0,0 +1,6 @@ +job "foo" { + constraint { + attribute = "$attr.kernel.version" + version = "~> 3.2" + } +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 098c57b32c74..09cef210a131 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -4,11 +4,13 @@ import ( "bytes" "errors" "fmt" + "regexp" "strings" "time" "github.com/hashicorp/go-msgpack/codec" "github.com/hashicorp/go-multierror" + "github.com/hashicorp/go-version" ) var ( @@ -809,6 +811,12 @@ func (j *Job) Validate() error { if len(j.TaskGroups) == 0 { mErr.Errors = append(mErr.Errors, errors.New("Missing job task groups")) } + for idx, constr := range j.Constraints { + if err := constr.Validate(); err != nil { + outer := fmt.Errorf("Constraint %d validation failed: %s", idx+1, err) + mErr.Errors = append(mErr.Errors, outer) + } + } // Check for duplicate task groups taskGroups := make(map[string]int) @@ -918,6 +926,12 @@ func (tg *TaskGroup) Validate() error { if len(tg.Tasks) == 0 { mErr.Errors = append(mErr.Errors, errors.New("Missing tasks for task group")) } + for idx, constr := range tg.Constraints { + if err := constr.Validate(); err != nil { + outer := fmt.Errorf("Constraint %d validation failed: %s", idx+1, err) + mErr.Errors = append(mErr.Errors, outer) + } + } // Check for duplicate tasks tasks := make(map[string]int) @@ -997,6 +1011,12 @@ func (t *Task) Validate() error { if t.Resources == nil { mErr.Errors = append(mErr.Errors, errors.New("Missing task resources")) } + for idx, constr := range t.Constraints { + if err := constr.Validate(); err != nil { + outer := fmt.Errorf("Constraint %d validation failed: %s", idx+1, err) + mErr.Errors = append(mErr.Errors, outer) + } + } return mErr.ErrorOrNil() } @@ -1015,6 +1035,26 @@ func (c *Constraint) String() string { return fmt.Sprintf("%s %s %s", c.LTarget, c.Operand, c.RTarget) } +func (c *Constraint) Validate() error { + var mErr multierror.Error + if c.Operand == "" { + mErr.Errors = append(mErr.Errors, errors.New("Missing constraint operand")) + } + + // Perform additional validation based on operand + switch c.Operand { + case "regexp": + if _, err := regexp.Compile(c.RTarget); err != nil { + mErr.Errors = append(mErr.Errors, fmt.Errorf("Regular expression failed to compile: %v", err)) + } + case "version": + if _, err := version.NewConstraint(c.RTarget); err != nil { + mErr.Errors = append(mErr.Errors, fmt.Errorf("Version constraint is invalid: %v", err)) + } + } + return mErr.ErrorOrNil() +} + const ( AllocDesiredStatusRun = "run" // Allocation should run AllocDesiredStatusStop = "stop" // Allocation should stop diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index ca72f540bc40..6df231a677d6 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -125,6 +125,43 @@ func TestTask_Validate(t *testing.T) { } } +func TestConstraint_Validate(t *testing.T) { + c := &Constraint{} + err := c.Validate() + mErr := err.(*multierror.Error) + if !strings.Contains(mErr.Errors[0].Error(), "Missing constraint operand") { + t.Fatalf("err: %s", err) + } + + c = &Constraint{ + LTarget: "$attr.kernel.name", + RTarget: "linux", + Operand: "=", + } + err = c.Validate() + if err != nil { + t.Fatalf("err: %v", err) + } + + // Perform additional regexp validation + c.Operand = "regexp" + c.RTarget = "(foo" + err = c.Validate() + mErr = err.(*multierror.Error) + if !strings.Contains(mErr.Errors[0].Error(), "missing closing") { + t.Fatalf("err: %s", err) + } + + // Perform version validation + c.Operand = "version" + c.RTarget = "~> foo" + err = c.Validate() + mErr = err.(*multierror.Error) + if !strings.Contains(mErr.Errors[0].Error(), "Malformed constraint") { + t.Fatalf("err: %s", err) + } +} + func TestResource_NetIndex(t *testing.T) { r := &Resources{ Networks: []*NetworkResource{ diff --git a/scheduler/feasible.go b/scheduler/feasible.go index d11a2034e278..7482a953de26 100644 --- a/scheduler/feasible.go +++ b/scheduler/feasible.go @@ -3,8 +3,10 @@ package scheduler import ( "fmt" "reflect" + "regexp" "strings" + "github.com/hashicorp/go-version" "github.com/hashicorp/nomad/nomad/structs" ) @@ -248,12 +250,99 @@ func checkConstraint(operand string, lVal, rVal interface{}) bool { case "!=", "not": return !reflect.DeepEqual(lVal, rVal) case "<", "<=", ">", ">=": - // TODO: Implement + return checkLexicalOrder(operand, lVal, rVal) + case "version": + return checkVersionConstraint(lVal, rVal) + case "regexp": + return checkRegexpConstraint(lVal, rVal) + default: + return false + } +} + +// checkLexicalOrder is used to check for lexical ordering +func checkLexicalOrder(op string, lVal, rVal interface{}) bool { + // Ensure the values are strings + lStr, ok := lVal.(string) + if !ok { return false - case "contains": - // TODO: Implement + } + rStr, ok := rVal.(string) + if !ok { + return false + } + + switch op { + case "<": + return lStr < rStr + case "<=": + return lStr <= rStr + case ">": + return lStr > rStr + case ">=": + return lStr >= rStr + default: return false + } +} + +// checkVersionConstraint is used to compare a version on the +// left hand side with a set of constraints on the right hand side +func checkVersionConstraint(lVal, rVal interface{}) bool { + // Parse the version + var versionStr string + switch v := lVal.(type) { + case string: + versionStr = v + case int: + versionStr = fmt.Sprintf("%d", v) default: return false } + + // Parse the verison + vers, err := version.NewVersion(versionStr) + if err != nil { + return false + } + + // Constraint must be a string + constraintStr, ok := rVal.(string) + if !ok { + return false + } + + // Parse the constraints + constraints, err := version.NewConstraint(constraintStr) + if err != nil { + return false + } + + // Check the constraints against the version + return constraints.Check(vers) +} + +// checkRegexpConstraint is used to compare a value on the +// left hand side with a regexp on the right hand side +func checkRegexpConstraint(lVal, rVal interface{}) bool { + // Ensure left-hand is string + lStr, ok := lVal.(string) + if !ok { + return false + } + + // Regexp must be a string + regexpStr, ok := rVal.(string) + if !ok { + return false + } + + // Parse the regexp + re, err := regexp.Compile(regexpStr) + if err != nil { + return false + } + + // Look for a match + return re.MatchString(lStr) } diff --git a/scheduler/feasible_test.go b/scheduler/feasible_test.go index e651dec6fb00..a69b4c8cebc9 100644 --- a/scheduler/feasible_test.go +++ b/scheduler/feasible_test.go @@ -244,6 +244,21 @@ func TestCheckConstraint(t *testing.T) { lVal: "foo", rVal: "bar", result: true, }, + { + op: "version", + lVal: "1.2.3", rVal: "~> 1.0", + result: true, + }, + { + op: "regexp", + lVal: "foobarbaz", rVal: "[\\w]+", + result: true, + }, + { + op: "<", + lVal: "foo", rVal: "bar", + result: false, + }, } for _, tc := range cases { @@ -253,6 +268,114 @@ func TestCheckConstraint(t *testing.T) { } } +func TestCheckLexicalOrder(t *testing.T) { + type tcase struct { + op string + lVal, rVal interface{} + result bool + } + cases := []tcase{ + { + op: "<", + lVal: "bar", rVal: "foo", + result: true, + }, + { + op: "<=", + lVal: "foo", rVal: "foo", + result: true, + }, + { + op: ">", + lVal: "bar", rVal: "foo", + result: false, + }, + { + op: ">=", + lVal: "bar", rVal: "bar", + result: true, + }, + { + op: ">", + lVal: 1, rVal: "foo", + result: false, + }, + } + for _, tc := range cases { + if res := checkLexicalOrder(tc.op, tc.lVal, tc.rVal); res != tc.result { + t.Fatalf("TC: %#v, Result: %v", tc, res) + } + } +} + +func TestCheckVersionConstraint(t *testing.T) { + type tcase struct { + lVal, rVal interface{} + result bool + } + cases := []tcase{ + { + lVal: "1.2.3", rVal: "~> 1.0", + result: true, + }, + { + lVal: "1.2.3", rVal: ">= 1.0, < 1.4", + result: true, + }, + { + lVal: "2.0.1", rVal: "~> 1.0", + result: false, + }, + { + lVal: "1.4", rVal: ">= 1.0, < 1.4", + result: false, + }, + { + lVal: 1, rVal: "~> 1.0", + result: true, + }, + } + for _, tc := range cases { + if res := checkVersionConstraint(tc.lVal, tc.rVal); res != tc.result { + t.Fatalf("TC: %#v, Result: %v", tc, res) + } + } +} + +func TestCheckRegexpConstraint(t *testing.T) { + type tcase struct { + lVal, rVal interface{} + result bool + } + cases := []tcase{ + { + lVal: "foobar", rVal: "bar", + result: true, + }, + { + lVal: "foobar", rVal: "^foo", + result: true, + }, + { + lVal: "foobar", rVal: "^bar", + result: false, + }, + { + lVal: "zipzap", rVal: "foo", + result: false, + }, + { + lVal: 1, rVal: "foo", + result: false, + }, + } + for _, tc := range cases { + if res := checkRegexpConstraint(tc.lVal, tc.rVal); res != tc.result { + t.Fatalf("TC: %#v, Result: %v", tc, res) + } + } +} + func collectFeasible(iter FeasibleIterator) (out []*structs.Node) { for { next := iter.Next() diff --git a/website/source/docs/jobspec/index.html.md b/website/source/docs/jobspec/index.html.md index 11c4af502326..a20217d0c780 100644 --- a/website/source/docs/jobspec/index.html.md +++ b/website/source/docs/jobspec/index.html.md @@ -217,11 +217,23 @@ The `constraint` object supports the following keys: to true. Soft constraints are not currently supported. * `operator` - Specifies the comparison operator. Defaults to equality, - and can be `=`, `==`, `is`, `!=`, `not`. + and can be `=`, `==`, `is`, `!=`, `not`, `>`, `>=`, `<`, `<=`. The + ordering is compared lexically. * `value` - Specifies the value to compare the attribute against. This can be a literal value or another attribute. +* `version` - Specifies a version constraint against the attribute. + This sets the operator to "version" and the `value` to what is + specified. This supports a comma seperated list of constraints, + including the pessimistic operator. See the + [go-version](https://github.com/hashicorp/go-version) repository + for examples. + +* `regexp` - Specifies a regular expression constraint against + the attribute. This sets the operator to "regexp" and the `value` + to the regular expression. + Below is a table documenting the variables that can be interpreted: