diff --git a/contrib/demo.sh b/contrib/demo.sh index 0d04da2e03..04727330b0 100644 --- a/contrib/demo.sh +++ b/contrib/demo.sh @@ -43,16 +43,16 @@ docker run -d -p 80:80 nginx # clean up cluster docker rm -f `docker ps -aq` -docker run -d -e "constraint:operatingsystem=fedora*" redis +docker run -d -e "constraint:operatingsystem==fedora*" redis docker ps -docker run -d -e constraint:storagedriver=devicemapper redis +docker run -d -e constraint:storagedriver==devicemapper redis docker ps -docker run -d -e constraint:storagedriver=aufs redis +docker run -d -e constraint:storagedriver==aufs redis docker ps -docker run -d -e constraint:node=fedora-1 redis +docker run -d -e constraint:node==fedora-1 redis docker ps # clean up cluster diff --git a/scheduler/filter/README.md b/scheduler/filter/README.md index 0f674d9a98..fad98b44f5 100644 --- a/scheduler/filter/README.md +++ b/scheduler/filter/README.md @@ -45,7 +45,7 @@ Once the nodes are registered with the cluster, the master pulls their respectiv Let's start a MySQL server and make sure it gets good I/O performance by selecting nodes with flash drives: ``` -$ docker run -d -P -e constraint:storage=ssd --name db mysql +$ docker run -d -P -e constraint:storage==ssd --name db mysql f8b693db9cd6 $ docker ps @@ -59,7 +59,7 @@ In this case, the master selected all nodes that met the `storage=ssd` constrain Now we want to run an `nginx` frontend in our cluster. However, we don't want *flash* drives since we'll mostly write logs to disk. ``` -$ docker run -d -P -e constraint:storage=disk --name frontend nginx +$ docker run -d -P -e constraint:storage==disk --name frontend nginx f8b693db9cd6 $ docker ps @@ -95,11 +95,11 @@ CONTAINER ID IMAGE COMMAND CREATED 87c4376856a8 nginx:latest "nginx" Less than a second ago running 192.168.0.42:80->80/tcp node-1 front ``` -Using `-e affinity:container=front` will schedule a container next to the container `front`. -You can also use IDs instead of name: `-e affinity:container=87c4376856a8` +Using `-e affinity:container==front` will schedule a container next to the container `front`. +You can also use IDs instead of name: `-e affinity:container==87c4376856a8` ``` -$ docker run -d --name logger -e affinity:container=front logger +$ docker run -d --name logger -e affinity:container==front logger 87c4376856a8 $ docker ps @@ -124,14 +124,14 @@ Here only `node-1` and `node-3` have the `redis` image. Using `-e affinity:image schedule container only on these 2 nodes. You can also use the image ID instead of it's name. ``` -$ docker run -d --name redis1 -e affinity:image=redis redis -$ docker run -d --name redis2 -e affinity:image=redis redis -$ docker run -d --name redis3 -e affinity:image=redis redis -$ docker run -d --name redis4 -e affinity:image=redis redis -$ docker run -d --name redis5 -e affinity:image=redis redis -$ docker run -d --name redis6 -e affinity:image=redis redis -$ docker run -d --name redis7 -e affinity:image=redis redis -$ docker run -d --name redis8 -e affinity:image=redis redis +$ docker run -d --name redis1 -e affinity:image==redis redis +$ docker run -d --name redis2 -e affinity:image==redis redis +$ docker run -d --name redis3 -e affinity:image==redis redis +$ docker run -d --name redis4 -e affinity:image==redis redis +$ docker run -d --name redis5 -e affinity:image==redis redis +$ docker run -d --name redis6 -e affinity:image==redis redis +$ docker run -d --name redis7 -e affinity:image==redis redis +$ docker run -d --name redis8 -e affinity:image==redis redis $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NODE NAMES @@ -147,6 +147,28 @@ CONTAINER ID IMAGE COMMAND CREATED As you can see here, the containers were only scheduled on nodes with the redis imagealreayd pulled. +#### Expression Syntax + +An affinity or a constraint expression consists of a `key` and a `value`. +A `key` must conform the alpha-numeric pattern, with the leading alphabet or underscore. + +A `value` must be one of the following: +* An alpha-numeric string, dots, hyphens, and underscores. +* A globbing pattern, i.e., `abc*`. +* A regular expression in the form of `/regexp/`. We support the Go's regular expression syntax. + +Current `swarm` supports affinity/constraint operators as the following: `==` and `!=`. + +For example, +* `constraint:name==node1` will match nodes named with `node1`. +* `constraint:name!=node1` will match all nodes, except `node1`. +* `constraint:region!=us*` will match all nodes outside the regions prefixed with `us`. +* `constraint:name==/node[12]/` will match nodes named `node1` and `node2`. +* `constraint:name==/node\d/` will match all nodes named with `node` + 1 digit. +* `constraint:node!=/node-[01]-id/` will match all nodes, except those with ids `node-0-id` and `node-1-id`. +* `constraint:name!=/foo\[bar\]/` will match all nodes, except those with name `foo[bar]`. You can see the use of escape characters here. +* `constraint:name==/(?i)node1/` will match all nodes named with `node1` case-insensitive. So 'NoDe1' or 'NODE1' will also matched. + ## Port Filter With this filter, `ports` are considered as a unique resource. diff --git a/scheduler/filter/affinity.go b/scheduler/filter/affinity.go index 54f84840c3..9722a31752 100644 --- a/scheduler/filter/affinity.go +++ b/scheduler/filter/affinity.go @@ -13,15 +13,20 @@ type AffinityFilter struct { } func (f *AffinityFilter) Filter(config *dockerclient.ContainerConfig, nodes []*cluster.Node) ([]*cluster.Node, error) { - affinities := extractEnv("affinity", config.Env) - for k, v := range affinities { - log.Debugf("matching affinity: %s=%s", k, v) + affinities, err := parseExprs("affinity", config.Env) + if err != nil { + return nil, err + } + + for _, affinity := range affinities { + log.Debugf("matching affinity: %s%s%s", affinity.key, OPERATORS[affinity.operator], affinity.value) + candidates := []*cluster.Node{} for _, node := range nodes { - switch k { + switch affinity.key { case "container": for _, container := range node.Containers() { - if match(v, container.Id) || match(v, container.Names[0]) { + if affinity.Match(container.Id, container.Names[0]) { candidates = append(candidates, node) break } @@ -29,12 +34,12 @@ func (f *AffinityFilter) Filter(config *dockerclient.ContainerConfig, nodes []*c case "image": done: for _, image := range node.Images() { - if match(v, image.Id) { + if affinity.Match(image.Id) { candidates = append(candidates, node) break } - for _, t := range image.RepoTags { - if match(v, t) { + for _, tag := range image.RepoTags { + if affinity.Match(tag) { candidates = append(candidates, node) break done } @@ -43,7 +48,7 @@ func (f *AffinityFilter) Filter(config *dockerclient.ContainerConfig, nodes []*c } } if len(candidates) == 0 { - return nil, fmt.Errorf("unable to find a node that satisfies %s == %s", k, v) + return nil, fmt.Errorf("unable to find a node that satisfies %s%s%s", affinity.key, OPERATORS[affinity.operator], affinity.value) } nodes = candidates } diff --git a/scheduler/filter/affinity_test.go b/scheduler/filter/affinity_test.go index ff997c9d8e..64f54ce7b8 100644 --- a/scheduler/filter/affinity_test.go +++ b/scheduler/filter/affinity_test.go @@ -56,13 +56,13 @@ func TestAffinityFilter(t *testing.T) { // Set a constraint that cannot be fullfilled and expect an error back. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:container=does_not_exsits"}, + Env: []string{"affinity:container==does_not_exsits"}, }, nodes) assert.Error(t, err) // Set a contraint that can only be filled by a single node. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:container=container-0*"}, + Env: []string{"affinity:container==container-0*"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -70,7 +70,7 @@ func TestAffinityFilter(t *testing.T) { // This constraint can only be fullfilled by a subset of nodes. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:container=container-*"}, + Env: []string{"affinity:container==container-*"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 2) @@ -78,23 +78,39 @@ func TestAffinityFilter(t *testing.T) { // Validate by id. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:container=container-0-id"}, + Env: []string{"affinity:container==container-0-id"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, result[0], nodes[0]) + // Validate by id. + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"affinity:container!=container-0-id"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.NotContains(t, result, nodes[0]) + // Validate by name. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:container=container-1-name"}, + Env: []string{"affinity:container==container-1-name"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, result[0], nodes[1]) + // Validate by name. + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"affinity:container!=container-1-name"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.NotContains(t, result, nodes[1]) + // Validate images by id result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:image=image-0-id"}, + Env: []string{"affinity:image==image-0-id"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -102,9 +118,30 @@ func TestAffinityFilter(t *testing.T) { // Validate images by name result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"affinity:image=image-0:tag3"}, + Env: []string{"affinity:image==image-0:tag3"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, result[0], nodes[1]) + + // Validate images by name + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"affinity:image!=image-0:tag3"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // Not support = any more + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"affinity:image=image-0:tag3"}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) + + // Not support =! any more + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"affinity:image=!image-0:tag3"}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) } diff --git a/scheduler/filter/constraint.go b/scheduler/filter/constraint.go index 2671b2390b..e994a98948 100644 --- a/scheduler/filter/constraint.go +++ b/scheduler/filter/constraint.go @@ -13,28 +13,32 @@ type ConstraintFilter struct { } func (f *ConstraintFilter) Filter(config *dockerclient.ContainerConfig, nodes []*cluster.Node) ([]*cluster.Node, error) { - constraints := extractEnv("constraint", config.Env) - for k, v := range constraints { - log.Debugf("matching constraint: %s=%s", k, v) + constraints, err := parseExprs("constraint", config.Env) + if err != nil { + return nil, err + } + + for _, constraint := range constraints { + log.Debugf("matching constraint: %s %s %s", constraint.key, OPERATORS[constraint.operator], constraint.value) + candidates := []*cluster.Node{} for _, node := range nodes { - switch k { + switch constraint.key { case "node": // "node" label is a special case pinning a container to a specific node. - if match(v, node.ID) || match(v, node.Name) { + if constraint.Match(node.ID, node.Name) { candidates = append(candidates, node) } default: - // By default match the node labels. - if label, ok := node.Labels[k]; ok { - if match(v, label) { + if label, ok := node.Labels[constraint.key]; ok { + if constraint.Match(label) { candidates = append(candidates, node) } } } } if len(candidates) == 0 { - return nil, fmt.Errorf("unable to find a node that satisfies %s == %s", k, v) + return nil, fmt.Errorf("unable to find a node that satisfies %s%s%s", constraint.key, OPERATORS[constraint.operator], constraint.value) } nodes = candidates } diff --git a/scheduler/filter/constraint_test.go b/scheduler/filter/constraint_test.go index 134dd94745..016aeeaada 100644 --- a/scheduler/filter/constraint_test.go +++ b/scheduler/filter/constraint_test.go @@ -8,18 +8,12 @@ import ( "github.com/stretchr/testify/assert" ) -func TestConstraintFilter(t *testing.T) { - var ( - f = ConstraintFilter{} - nodes = []*cluster.Node{ - cluster.NewNode("node-0", 0), - cluster.NewNode("node-1", 0), - cluster.NewNode("node-2", 0), - } - result []*cluster.Node - err error - ) - +func testFixtures() (nodes []*cluster.Node) { + nodes = []*cluster.Node{ + cluster.NewNode("node-0", 0), + cluster.NewNode("node-1", 0), + cluster.NewNode("node-2", 0), + } nodes[0].ID = "node-0-id" nodes[0].Name = "node-0-name" nodes[0].Labels = map[string]string{ @@ -43,6 +37,16 @@ func TestConstraintFilter(t *testing.T) { "group": "2", "region": "eu", } + return +} + +func TestConstrainteFilter(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) // Without constraints we should get the unfiltered list of nodes back. result, err = f.Filter(&dockerclient.ContainerConfig{}, nodes) @@ -51,13 +55,13 @@ func TestConstraintFilter(t *testing.T) { // Set a constraint that cannot be fullfilled and expect an error back. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:does_not_exist=true"}, + Env: []string{"constraint:does_not_exist==true"}, }, nodes) assert.Error(t, err) // Set a contraint that can only be filled by a single node. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:name=node1"}, + Env: []string{"constraint:name==node1"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -65,7 +69,7 @@ func TestConstraintFilter(t *testing.T) { // This constraint can only be fullfilled by a subset of nodes. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:group=1"}, + Env: []string{"constraint:group==1"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 2) @@ -73,7 +77,7 @@ func TestConstraintFilter(t *testing.T) { // Validate node pinning by id. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:node=node-2-id"}, + Env: []string{"constraint:node==node-2-id"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -81,7 +85,7 @@ func TestConstraintFilter(t *testing.T) { // Validate node pinning by name. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:node=node-1-name"}, + Env: []string{"constraint:node==node-1-name"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -89,7 +93,7 @@ func TestConstraintFilter(t *testing.T) { // Make sure constraints are evaluated as logical ANDs. result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:name=node0", "constraint:group=1"}, + Env: []string{"constraint:name==node0", "constraint:group==1"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 1) @@ -97,14 +101,244 @@ func TestConstraintFilter(t *testing.T) { // Check matching result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:region=us"}, + Env: []string{"constraint:region==us"}, }, nodes) assert.Error(t, err) assert.Len(t, result, 0) result, err = f.Filter(&dockerclient.ContainerConfig{ - Env: []string{"constraint:region=us*"}, + Env: []string{"constraint:region==us*"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 2) + + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:region==*us*"}, }, nodes) assert.NoError(t, err) assert.Len(t, result, 2) } + +func TestConstraintNotExpr(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + // Check not (!) expression + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name!=node0"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // Check not does_not_exist. All should be found + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name!=does_not_exist"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 3) + + // Check name must not start with n + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name!=n*"}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) + + // Check not with globber pattern + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:region!=us*"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0].Labels["region"], "eu") +} + +func TestConstraintRegExp(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + // Check with regular expression /node\d/ matches node{0..2} + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name==/node\d/`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 3) + + // Check with regular expression /node\d/ matches node{0..2} + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name==/node[12]/`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // Check with regular expression ! and regexp /node[12]/ matches node[0] + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name!=/node[12]/`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0], nodes[0]) + + // Validate node pinning by ! and regexp. + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:node!=/node-[01]-id/"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0], nodes[2]) +} + +func TestFilterRegExpCaseInsensitive(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + // Prepare node with a strange name + node3 := cluster.NewNode("node-3", 0) + node3.ID = "node-3-id" + node3.Name = "node-3-name" + node3.Labels = map[string]string{ + "name": "aBcDeF", + "group": "2", + "region": "eu", + } + nodes = append(nodes, node3) + + // Case-sensitive, so not match + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name==/abcdef/`}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) + + // Match with case-insensitive + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name==/(?i)abcdef/`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0], nodes[3]) + assert.Equal(t, result[0].Labels["name"], "aBcDeF") + + // Test ! filter combined with case insensitive + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:name!=/(?i)abc*/`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 3) +} + +func TestFilterWithRelativeComparisons(t *testing.T) { + t.Skip() + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + // Prepare node with a strange name + node3 := cluster.NewNode("node-3", 0) + node3.ID = "node-3-id" + node3.Name = "node-3-name" + node3.Labels = map[string]string{ + "name": "aBcDeF", + "group": "4", + "kernel": "3.1", + "region": "eu", + } + nodes = append(nodes, node3) + + // Check with less than or equal + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:group<=3`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 3) + + // Check with greater than or equal + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:group>=4`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + + // Another gte check with a complex string + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:kernel>=3.0`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0], nodes[3]) + assert.Equal(t, result[0].Labels["kernel"], "3.1") + + // Check with greater than or equal. This should match node-3-id. + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{`constraint:node>=node-3`}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) +} + +func TestFilterEquals(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + // Check == comparison + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name==node0"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + + // Test == with glob + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:region==us*"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // Validate node name with == + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:node==node-1-name"}, + }, nodes) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, result[0], nodes[1]) +} + +func TestUnsupportedOperators(t *testing.T) { + var ( + f = ConstraintFilter{} + nodes = testFixtures() + result []*cluster.Node + err error + ) + + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name=node0"}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) + + result, err = f.Filter(&dockerclient.ContainerConfig{ + Env: []string{"constraint:name=!node0"}, + }, nodes) + assert.Error(t, err) + assert.Len(t, result, 0) +} diff --git a/scheduler/filter/expr.go b/scheduler/filter/expr.go new file mode 100644 index 0000000000..cbb526e121 --- /dev/null +++ b/scheduler/filter/expr.go @@ -0,0 +1,106 @@ +package filter + +import ( + "fmt" + "regexp" + "strings" + + log "github.com/Sirupsen/logrus" +) + +const ( + EQ = iota + NOTEQ +) + +var OPERATORS = []string{"==", "!="} + +type expr struct { + key string + operator int + value string +} + +func parseExprs(key string, env []string) ([]expr, error) { + exprs := []expr{} + for _, e := range env { + if strings.HasPrefix(e, key+":") { + entry := strings.TrimPrefix(e, key+":") + found := false + for i, op := range OPERATORS { + if strings.Contains(entry, op) { + // split with the op + parts := strings.SplitN(entry, op, 2) + + // validate key + // allow alpha-numeric + matched, err := regexp.MatchString(`^(?i)[a-z_][a-z0-9\-_]+$`, parts[0]) + if err != nil { + return nil, err + } + if matched == false { + return nil, fmt.Errorf("Key '%s' is invalid", parts[0]) + } + + if len(parts) == 2 { + + // validate value + // allow leading = in case of using == + // allow * for globbing + // allow regexp + matched, err := regexp.MatchString(`^(?i)[=!\/]?[a-z0-9:\-_\.\*/\(\)\?\+\[\]\\\^\$]+$`, parts[1]) + if err != nil { + return nil, err + } + if matched == false { + return nil, fmt.Errorf("Value '%s' is invalid", parts[1]) + } + exprs = append(exprs, expr{key: strings.ToLower(parts[0]), operator: i, value: parts[1]}) + } else { + exprs = append(exprs, expr{key: strings.ToLower(parts[0]), operator: i}) + } + + found = true + break // found an op, move to next entry + } + } + if !found { + return nil, fmt.Errorf("One of operator ==, != is expected") + } + } + } + return exprs, nil +} + +func (e *expr) Match(whats ...string) bool { + var ( + pattern string + match bool + err error + ) + + if e.value[0] == '/' && e.value[len(e.value)-1] == '/' { + // regexp + pattern = e.value[1 : len(e.value)-1] + } else { + // simple match, create the regex for globbing (ex: ub*t* -> ^ub.*t.*$) and match. + pattern = "^" + strings.Replace(e.value, "*", ".*", -1) + "$" + } + + for _, what := range whats { + if match, err = regexp.MatchString(pattern, what); match { + break + } else if err != nil { + log.Error(err) + } + } + + switch e.operator { + case EQ: + return match + case NOTEQ: + return !match + } + + return false +} diff --git a/scheduler/filter/expr_test.go b/scheduler/filter/expr_test.go new file mode 100644 index 0000000000..1f7a357577 --- /dev/null +++ b/scheduler/filter/expr_test.go @@ -0,0 +1,59 @@ +package filter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseExprs(t *testing.T) { + // Cannot use the leading digit for key + _, err := parseExprs("constraint", []string{"constraint:1node"}) + assert.Error(t, err) + + // Cannot use space in key + _, err = parseExprs("constraint", []string{"constraint:node ==node1"}) + assert.Error(t, err) + + // Cannot use dot in key + _, err = parseExprs("constraint", []string{"constraint:no.de==node1"}) + assert.Error(t, err) + + // Cannot use * in key + _, err = parseExprs("constraint", []string{"constraint:no*de==node1"}) + assert.Error(t, err) + + // Allow leading underscore + _, err = parseExprs("constraint", []string{"constraint:_node==_node1"}) + assert.NoError(t, err) + + // Allow globbing + _, err = parseExprs("constraint", []string{"constraint:node==*node*"}) + assert.NoError(t, err) + + // Allow regexp in value + _, err = parseExprs("constraint", []string{"constraint:node==/(?i)^[a-b]+c*$/"}) + assert.NoError(t, err) +} + +func TestMatch(t *testing.T) { + e := expr{operator: EQ, value: "foo"} + assert.True(t, e.Match("foo")) + assert.False(t, e.Match("bar")) + assert.True(t, e.Match("foo", "bar")) + + e = expr{operator: NOTEQ, value: "foo"} + assert.False(t, e.Match("foo")) + assert.True(t, e.Match("bar")) + assert.False(t, e.Match("foo", "bar")) + + e = expr{operator: EQ, value: "f*o"} + assert.True(t, e.Match("foo")) + assert.True(t, e.Match("fuo")) + assert.True(t, e.Match("foo", "fuo", "bar")) + + e = expr{operator: NOTEQ, value: "f*o"} + assert.False(t, e.Match("foo")) + assert.False(t, e.Match("fuo")) + assert.False(t, e.Match("foo", "fuo", "bar")) +} diff --git a/scheduler/filter/utils.go b/scheduler/filter/utils.go deleted file mode 100644 index 31a3fdf340..0000000000 --- a/scheduler/filter/utils.go +++ /dev/null @@ -1,34 +0,0 @@ -package filter - -import ( - "regexp" - "strings" - - log "github.com/Sirupsen/logrus" -) - -func extractEnv(key string, env []string) map[string]string { - values := make(map[string]string) - for _, e := range env { - if strings.HasPrefix(e, key+":") { - value := strings.TrimPrefix(e, key+":") - parts := strings.SplitN(value, "=", 2) - if len(parts) == 2 { - values[strings.ToLower(parts[0])] = strings.ToLower(parts[1]) - } else { - values[strings.ToLower(parts[0])] = "" - } - } - } - return values -} - -// Create the regex for globbing (ex: ub*t* -> ^ub.*t.*$) and match. -func match(pattern, s string) bool { - regex := "^" + strings.Replace(pattern, "*", ".*", -1) + "$" - matched, err := regexp.MatchString(regex, strings.ToLower(s)) - if err != nil { - log.Error(err) - } - return matched -}