diff --git a/integration/constraint_test.go b/integration/constraint_test.go new file mode 100644 index 000000000..50e194d2e --- /dev/null +++ b/integration/constraint_test.go @@ -0,0 +1,211 @@ +package main + +import ( + //"io/ioutil" + //"fmt" + "net/http" + "os/exec" + "time" + + "github.com/go-check/check" + "github.com/hashicorp/consul/api" + + checker "github.com/vdemeester/shakers" +) + +// Constraint test suite +type ConstraintSuite struct { + BaseSuite + consulIP string + consulClient *api.Client +} + +func (s *ConstraintSuite) SetUpSuite(c *check.C) { + + s.createComposeProject(c, "constraints") + s.composeProject.Start(c) + + consul := s.composeProject.Container(c, "consul") + + s.consulIP = consul.NetworkSettings.IPAddress + config := api.DefaultConfig() + config.Address = s.consulIP + ":8500" + consulClient, err := api.NewClient(config) + if err != nil { + c.Fatalf("Error creating consul client") + } + s.consulClient = consulClient + + // Wait for consul to elect itself leader + time.Sleep(2000 * time.Millisecond) +} + +func (s *ConstraintSuite) registerService(name string, address string, port int, tags []string) error { + catalog := s.consulClient.Catalog() + _, err := catalog.Register( + &api.CatalogRegistration{ + Node: address, + Address: address, + Service: &api.AgentService{ + ID: name, + Service: name, + Address: address, + Port: port, + Tags: tags, + }, + }, + &api.WriteOptions{}, + ) + return err +} + +func (s *ConstraintSuite) deregisterService(name string, address string) error { + catalog := s.consulClient.Catalog() + _, err := catalog.Deregister( + &api.CatalogDeregistration{ + Node: address, + Address: address, + ServiceID: name, + }, + &api.WriteOptions{}, + ) + return err +} + +func (s *ConstraintSuite) TestMatchConstraintGlobal(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--constraints=tag==api") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{"traefik.tags=api"}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 200) +} + +func (s *ConstraintSuite) TestDoesNotMatchConstraintGlobal(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--constraints=tag==api") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 404) +} + +func (s *ConstraintSuite) TestMatchConstraintProvider(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--consulCatalog.constraints=tag==api") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{"traefik.tags=api"}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 200) +} + +func (s *ConstraintSuite) TestDoesNotMatchConstraintProvider(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--consulCatalog.constraints=tag==api") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 404) +} + +func (s *ConstraintSuite) TestMatchMultipleConstraint(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--consulCatalog.constraints=tag==api", "--constraints=tag!=us-*") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{"traefik.tags=api", "traefik.tags=eu-1"}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 200) +} + +func (s *ConstraintSuite) TestDoesNotMatchMultipleConstraint(c *check.C) { + cmd := exec.Command(traefikBinary, "--consulCatalog", "--consulCatalog.endpoint="+s.consulIP+":8500", "--consulCatalog.domain=consul.localhost", "--configFile=fixtures/consul_catalog/simple.toml", "--consulCatalog.constraints=tag==api", "--constraints=tag!=us-*") + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + nginx := s.composeProject.Container(c, "nginx") + + err = s.registerService("test", nginx.NetworkSettings.IPAddress, 80, []string{"traefik.tags=api", "traefik.tags=us-1"}) + c.Assert(err, checker.IsNil, check.Commentf("Error registering service")) + defer s.deregisterService("test", nginx.NetworkSettings.IPAddress) + + time.Sleep(5000 * time.Millisecond) + client := &http.Client{} + req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil) + c.Assert(err, checker.IsNil) + req.Host = "test.consul.localhost" + resp, err := client.Do(req) + + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, 404) +} diff --git a/integration/integration_test.go b/integration/integration_test.go index c31832a19..9d7bff2bb 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -31,6 +31,7 @@ func init() { check.Suite(&ConsulCatalogSuite{}) check.Suite(&EtcdSuite{}) check.Suite(&MarathonSuite{}) + check.Suite(&ConstraintSuite{}) } var traefikBinary = "../dist/traefik" diff --git a/integration/resources/compose/constraints.yml b/integration/resources/compose/constraints.yml new file mode 100644 index 000000000..9a2688904 --- /dev/null +++ b/integration/resources/compose/constraints.yml @@ -0,0 +1,17 @@ +consul: + image: progrium/consul + command: -server -bootstrap -log-level debug -ui-dir /ui + ports: + - "8400:8400" + - "8500:8500" + - "8600:53/udp" + expose: + - "8300" + - "8301" + - "8301/udp" + - "8302" + - "8302/udp" +nginx: + image: nginx + ports: + - "8881:80" diff --git a/provider/consul_catalog.go b/provider/consul_catalog.go index ec82ecb39..0b0d2294b 100644 --- a/provider/consul_catalog.go +++ b/provider/consul_catalog.go @@ -7,6 +7,7 @@ import ( "text/template" "time" + "github.com/BurntSushi/ty/fun" log "github.com/Sirupsen/logrus" "github.com/cenkalti/backoff" "github.com/containous/traefik/safe" @@ -88,28 +89,22 @@ func (provider *ConsulCatalog) healthyNodes(service string) (catalogUpdate, erro return catalogUpdate{}, err } - set := map[string]bool{} - tags := []string{} - nodes := []*api.ServiceEntry{} - for _, node := range data { + nodes := fun.Filter(func(node *api.ServiceEntry) bool { constraintTags := provider.getContraintTags(node.Service.Tags) - if ok, failingConstraint, err := provider.MatchConstraints(constraintTags); err != nil { - return catalogUpdate{}, err - } else if ok == true { - nodes = append(nodes, node) - // merge tags of every nodes in a single slice - // only if node match constraint - for _, tag := range node.Service.Tags { - if _, ok := set[tag]; ok == false { - set[tag] = true - tags = append(tags, tag) - } - } - } else if ok == false && failingConstraint != nil { + ok, failingConstraint := provider.MatchConstraints(constraintTags) + if ok == false && failingConstraint != nil { log.Debugf("Service %v pruned by '%v' constraint", service, failingConstraint.String()) } + return ok + }, data).([]*api.ServiceEntry) - } + //Merge tags of nodes matching constraints, in a single slice. + tags := fun.Foldl(func(node *api.ServiceEntry, set []string) []string { + return fun.Keys(fun.Union( + fun.Set(set), + fun.Set(node.Service.Tags), + ).(map[string]bool)).([]string) + }, []string{}, nodes).([]string) return catalogUpdate{ Service: &serviceUpdate{ diff --git a/provider/provider.go b/provider/provider.go index 5355ae3d8..43d662401 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -29,22 +29,21 @@ type BaseProvider struct { // MatchConstraints must match with EVERY single contraint // returns first constraint that do not match or nil -// returns errors for future use (regex) -func (p *BaseProvider) MatchConstraints(tags []string) (bool, *types.Constraint, error) { +func (p *BaseProvider) MatchConstraints(tags []string) (bool, *types.Constraint) { // if there is no tags and no contraints, filtering is disabled if len(tags) == 0 && len(p.Constraints) == 0 { - return true, nil, nil + return true, nil } for _, constraint := range p.Constraints { - if ok := constraint.MatchConstraintWithAtLeastOneTag(tags); xor(ok == true, constraint.MustMatch == true) { - return false, constraint, nil + // xor: if ok and constraint.MustMatch are equal, then no tag is currently matching with the constraint + if ok := constraint.MatchConstraintWithAtLeastOneTag(tags); ok != constraint.MustMatch { + return false, constraint } } // If no constraint or every constraints matching - return true, nil, nil ->>>>>>> e844462... feat(constraints): Implementation of constraints (cmd + toml + matching functions), implementation proposal with consul + return true, nil } func (p *BaseProvider) getConfiguration(defaultTemplateFile string, funcMap template.FuncMap, templateObjects interface{}) (*types.Configuration, error) { @@ -98,8 +97,3 @@ func normalize(name string) string { // get function return strings.Join(strings.FieldsFunc(name, fargs), "-") } - -// golang does not support ^ operator -func xor(cond1 bool, cond2 bool) bool { - return cond1 != cond2 -} diff --git a/provider/provider_test.go b/provider/provider_test.go index b76f5e6bd..824d3ced0 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "text/template" + + "github.com/containous/traefik/types" ) type myProvider struct { @@ -206,3 +208,100 @@ func TestGetConfigurationReturnsCorrectMaxConnConfiguration(t *testing.T) { t.Fatalf("Configuration did not parse MaxConn.ExtractorFunc properly") } } + +func TestMatchingConstraints(t *testing.T) { + cases := []struct { + constraints []*types.Constraint + tags []string + expected bool + }{ + // simple test: must match + { + constraints: []*types.Constraint{ + { + Key: "tag", + MustMatch: true, + Regex: "us-east-1", + }, + }, + tags: []string{ + "us-east-1", + }, + expected: true, + }, + // simple test: must match but does not match + { + constraints: []*types.Constraint{ + { + Key: "tag", + MustMatch: true, + Regex: "us-east-1", + }, + }, + tags: []string{ + "us-east-2", + }, + expected: false, + }, + // simple test: must not match + { + constraints: []*types.Constraint{ + { + Key: "tag", + MustMatch: false, + Regex: "us-east-1", + }, + }, + tags: []string{ + "us-east-1", + }, + expected: false, + }, + // complex test: globbing + { + constraints: []*types.Constraint{ + { + Key: "tag", + MustMatch: true, + Regex: "us-east-*", + }, + }, + tags: []string{ + "us-east-1", + }, + expected: true, + }, + // complex test: multiple constraints + { + constraints: []*types.Constraint{ + { + Key: "tag", + MustMatch: true, + Regex: "us-east-*", + }, + { + Key: "tag", + MustMatch: false, + Regex: "api", + }, + }, + tags: []string{ + "api", + "us-east-1", + }, + expected: false, + }, + } + + for i, c := range cases { + provider := myProvider{ + BaseProvider{ + Constraints: c.constraints, + }, + } + actual, _ := provider.MatchConstraints(c.tags) + if actual != c.expected { + t.Fatalf("test #%v: expected %q, got %q, for %q", i, c.expected, actual, c.constraints) + } + } +} diff --git a/types/types.go b/types/types.go index 5eaa1e51e..2ce5ceb97 100644 --- a/types/types.go +++ b/types/types.go @@ -106,6 +106,7 @@ type Constraint struct { Regex string } +// NewConstraint receive a string and return a *Constraint, after checking syntax and parsing the constraint expression func NewConstraint(exp string) (*Constraint, error) { sep := "" constraint := &Constraint{} @@ -142,6 +143,7 @@ func (c *Constraint) String() string { return c.Key + "!=" + c.Regex } +// MatchConstraintWithAtLeastOneTag tests a constraint for one single service func (c *Constraint) MatchConstraintWithAtLeastOneTag(tags []string) bool { for _, tag := range tags { if glob.Glob(c.Regex, tag) { @@ -165,41 +167,44 @@ func StringToConstraintHookFunc() mapstructure.DecodeHookFunc { return data, nil } - if constraint, err := NewConstraint(data.(string)); err != nil { + constraint, err := NewConstraint(data.(string)) + if err != nil { return data, err - } else { - return constraint, nil } + return constraint, nil } } +// Constraints own a pointer on globalConfiguration.Constraints and supports a Set() method (not possible on a slice) +// interface: type Constraints struct { value *[]*Constraint changed bool } -// Command line +// Set receive a cli argument and add it to globalConfiguration func (cs *Constraints) Set(value string) error { exps := strings.Split(value, ",") if len(exps) == 0 { return errors.New("Bad Constraint format: " + value) } for _, exp := range exps { - if constraint, err := NewConstraint(exp); err != nil { + constraint, err := NewConstraint(exp) + if err != nil { return err - } else { - *cs.value = append(*cs.value, constraint) } + *cs.value = append(*cs.value, constraint) } return nil } -func (c *Constraints) Type() string { +// Type exports the Constraints type as a string +func (cs *Constraints) Type() string { return "constraints" } -func (c *Constraints) String() string { - return fmt.Sprintln("%v", *c.value) +func (cs *Constraints) String() string { + return fmt.Sprintln("%v", *cs.value) } // NewConstraintSliceValue make an alias of []*Constraint to Constraints for the command line