diff --git a/docs/content/providers/consul-catalog.md b/docs/content/providers/consul-catalog.md index 3c601da31..cdec6ae86 100644 --- a/docs/content/providers/consul-catalog.md +++ b/docs/content/providers/consul-catalog.md @@ -714,6 +714,32 @@ providers: # ... ``` +### `strictChecks` + +_Optional, Default="passing,warning"_ + +Define which [Consul Service health checks](https://developer.hashicorp.com/consul/docs/services/usage/checks#define-initial-health-check-status) are allowed to take on traffic. + +```yaml tab="File (YAML)" +providers: + consulCatalog: + strictChecks: + - "passing" + - "warning" + # ... +``` + +```toml tab="File (TOML)" +[providers.consulCatalog] + strictChecks = ["passing", "warning"] + # ... +``` + +```bash tab="CLI" +--providers.consulcatalog.strictChecks=passing,warning +# ... +``` + ### `watch` _Optional, Default=false_ diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 179e376c5..d78150a72 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -537,6 +537,9 @@ Name of the Traefik service in Consul Catalog (needs to be registered via the or `--providers.consulcatalog.stale`: Use stale consistency for catalog reads. (Default: ```false```) +`--providers.consulcatalog.strictchecks`: +A list of service health statuses to allow taking traffic. (Default: ```passing, warning```) + `--providers.consulcatalog.watch`: Watch Consul API events. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index e2a2e73f6..7947b9ddd 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -513,6 +513,9 @@ Name of the Traefik service in Consul Catalog (needs to be registered via the or `TRAEFIK_PROVIDERS_CONSULCATALOG_STALE`: Use stale consistency for catalog reads. (Default: ```false```) +`TRAEFIK_PROVIDERS_CONSULCATALOG_STRICTCHECKS`: +A list of service health statuses to allow taking traffic. (Default: ```passing, warning```) + `TRAEFIK_PROVIDERS_CONSULCATALOG_WATCH`: Watch Consul API events. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index e3de7aa3b..504d7c9b6 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -161,6 +161,7 @@ connectByDefault = true serviceName = "foobar" watch = true + strictChecks = ["foobar", "foobar"] namespaces = ["foobar", "foobar"] [providers.consulCatalog.endpoint] address = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index bdf314c29..ffb9d75d8 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -192,6 +192,9 @@ providers: connectByDefault: true serviceName: foobar watch: true + strictChecks: + - foobar + - foobar namespaces: - foobar - foobar diff --git a/pkg/provider/consulcatalog/config.go b/pkg/provider/consulcatalog/config.go index a45696fd4..98c320013 100644 --- a/pkg/provider/consulcatalog/config.go +++ b/pkg/provider/consulcatalog/config.go @@ -132,8 +132,8 @@ func (p *Provider) keepContainer(ctx context.Context, item itemData) bool { return false } - if item.Status != api.HealthPassing && item.Status != api.HealthWarning { - logger.Debug().Msg("Filtering unhealthy or starting item") + if !p.includesHealthStatus(item.Status) { + logger.Debug().Msgf("Status %q is not included in the configured strictChecks of %q", item.Status, strings.Join(p.StrictChecks, ",")) return false } @@ -324,3 +324,8 @@ func getName(i itemData) string { hasher.Write([]byte(strings.Join(tags, ""))) return provider.Normalize(fmt.Sprintf("%s-%d", i.Name, hasher.Sum64())) } + +// defaultStrictChecks returns the default healthchecks to allow an upstream to be registered a route for loadbalancers. +func defaultStrictChecks() []string { + return []string{api.HealthPassing, api.HealthWarning} +} diff --git a/pkg/provider/consulcatalog/config_test.go b/pkg/provider/consulcatalog/config_test.go index 5370a75ca..4649eb3bd 100644 --- a/pkg/provider/consulcatalog/config_test.go +++ b/pkg/provider/consulcatalog/config_test.go @@ -287,11 +287,13 @@ func TestDefaultRule(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() + var config Configuration + + config.SetDefaults() + config.DefaultRule = test.defaultRule + p := Provider{ - Configuration: Configuration{ - ExposedByDefault: true, - DefaultRule: test.defaultRule, - }, + Configuration: config, } err := p.Init() @@ -3125,13 +3127,15 @@ func Test_buildConfiguration(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() + var config Configuration + + config.SetDefaults() + config.DefaultRule = "Host(`{{ normalize .Name }}.traefik.wtf`)" + config.ConnectAware = test.ConnectAware + config.Constraints = test.constraints + p := Provider{ - Configuration: Configuration{ - ExposedByDefault: true, - DefaultRule: "Host(`{{ normalize .Name }}.traefik.wtf`)", - ConnectAware: test.ConnectAware, - Constraints: test.constraints, - }, + Configuration: config, } err := p.Init() @@ -3206,3 +3210,449 @@ func extractNSFromProvider(providers []*Provider) []string { } return res } + +func TestFilterHealthStatuses(t *testing.T) { + testCases := []struct { + desc string + items []itemData + strictChecks []string + expected *dynamic.Configuration + }{ + { + // No value passed in here, we assume the default of ["passing", "warning"] + desc: "test default strict checks", + strictChecks: defaultStrictChecks(), + items: []itemData{ + { + ID: "id", + Node: "Node1", + Name: "Test1", + Address: "127.0.0.1", + Port: "80", + Labels: nil, + Status: api.HealthPassing, + }, + { + ID: "id", + Node: "Node2", + Name: "Test2", + Address: "127.0.0.1", + Port: "81", + Labels: nil, + Status: api.HealthWarning, + }, + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "Test1": { + Service: "Test1", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + "Test2": { + Service: "Test2", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "Test1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:80", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + "Test2": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:81", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + }, + }, + { + // The item's health status is not included in the default checks, do not expect any containers + desc: "test status not included", + strictChecks: defaultStrictChecks(), + items: []itemData{ + { + ID: "id", + Node: "Node1", + Name: "Test", + Address: "127.0.0.1", + Port: "80", + Labels: nil, + Status: api.HealthCritical, + }, + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{}, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + }, + }, + { + // Allow only "warning" status containers to be included + desc: "test only include warning", + strictChecks: []string{api.HealthWarning}, + items: []itemData{ + { + ID: "id", + Node: "Node1", + Name: "Test1", + Address: "127.0.0.1", + Port: "80", + Labels: nil, + Status: api.HealthPassing, + }, + { + ID: "id2", + Node: "Node2", + Name: "Test2", + Address: "127.0.0.1", + Port: "81", + Labels: nil, + Status: api.HealthWarning, + }, + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "Test2": { + Service: "Test2", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "Test2": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:81", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + }, + }, + { + // Reject "critical" health status + desc: "test critical status not included", + strictChecks: defaultStrictChecks(), + items: []itemData{ + { + ID: "id", + Node: "Node1", + Name: "Test1", + Address: "127.0.0.1", + Port: "80", + Labels: nil, + Status: api.HealthPassing, + }, + { + ID: "id2", + Node: "Node2", + Name: "Test2", + Address: "127.0.0.1", + Port: "81", + Labels: nil, + Status: api.HealthWarning, + }, + { + ID: "id3", + Node: "Node3", + Name: "Test3", + Address: "127.0.0.1", + Port: "82", + Labels: nil, + Status: api.HealthCritical, + }, + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "Test1": { + Service: "Test1", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + "Test2": { + Service: "Test2", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "Test1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:80", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + "Test2": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:81", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + }, + }, + { + // The "any" health status allows for all status types, including ones not yet directly included in Consul + desc: "test include 'any' health status", + strictChecks: []string{api.HealthAny}, + items: []itemData{ + { + ID: "id", + Node: "Node1", + Name: "Test1", + Address: "127.0.0.1", + Port: "80", + Labels: nil, + Status: api.HealthPassing, + }, + { + ID: "id2", + Node: "Node2", + Name: "Test2", + Address: "127.0.0.1", + Port: "81", + Labels: nil, + Status: api.HealthWarning, + }, + { + ID: "id3", + Node: "Node3", + Name: "Test3", + Address: "127.0.0.1", + Port: "82", + Labels: nil, + Status: api.HealthCritical, + }, + { + ID: "id4", + Node: "Node4", + Name: "Test4", + Address: "127.0.0.1", + Port: "83", + Labels: nil, + Status: "some unsupported status", + }, + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "Test1": { + Service: "Test1", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + "Test2": { + Service: "Test2", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + "Test3": { + Service: "Test3", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + "Test4": { + Service: "Test4", + Rule: "Host(`foo.bar`)", + DefaultRule: true, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "Test1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:80", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + "Test2": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:81", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + "Test3": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:82", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + "Test4": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://127.0.0.1:83", + }, + }, + PassHostHeader: Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var config Configuration + + config.SetDefaults() + config.DefaultRule = "Host(`foo.bar`)" + + if test.strictChecks != nil { + config.StrictChecks = test.strictChecks + } + + p := Provider{ + Configuration: config, + } + + err := p.Init() + require.NoError(t, err) + + for i := 0; i < len(test.items); i++ { + var err error + test.items[i].ExtraConf, err = p.getExtraConf(test.items[i].Labels) + require.NoError(t, err) + } + + configuration := p.buildConfiguration(context.Background(), test.items, nil) + + assert.Equal(t, test.expected, configuration) + }) + } +} diff --git a/pkg/provider/consulcatalog/consul_catalog.go b/pkg/provider/consulcatalog/consul_catalog.go index 696214e0c..55c24edb6 100644 --- a/pkg/provider/consulcatalog/consul_catalog.go +++ b/pkg/provider/consulcatalog/consul_catalog.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "text/template" "time" @@ -88,6 +89,7 @@ type Configuration struct { ConnectByDefault bool `description:"Consider every service as Connect capable by default." json:"connectByDefault,omitempty" toml:"connectByDefault,omitempty" yaml:"connectByDefault,omitempty" export:"true"` ServiceName string `description:"Name of the Traefik service in Consul Catalog (needs to be registered via the orchestrator or manually)." json:"serviceName,omitempty" toml:"serviceName,omitempty" yaml:"serviceName,omitempty" export:"true"` Watch bool `description:"Watch Consul API events." json:"watch,omitempty" toml:"watch,omitempty" yaml:"watch,omitempty" export:"true"` + StrictChecks []string `description:"A list of service health statuses to allow taking traffic." json:"strictChecks,omitempty" toml:"strictChecks,omitempty" yaml:"strictChecks,omitempty" export:"true"` } // SetDefaults sets the default values. @@ -98,6 +100,7 @@ func (c *Configuration) SetDefaults() { c.ExposedByDefault = true c.DefaultRule = defaultTemplateRule c.ServiceName = "traefik" + c.StrictChecks = defaultStrictChecks() } // Provider is the Consul Catalog provider implementation. @@ -578,6 +581,21 @@ func (p *Provider) watchConnectTLS(ctx context.Context) error { } } +// includesHealthStatus returns true if the status passed in exists in the configured StrictChecks configuration. Statuses are case insensitive. +func (p *Provider) includesHealthStatus(status string) bool { + for _, s := range p.StrictChecks { + // If the "any" status is included, assume all health checks are included + if strings.EqualFold(s, api.HealthAny) { + return true + } + + if strings.EqualFold(s, status) { + return true + } + } + return false +} + func createClient(namespace string, endpoint *EndpointConfig) (*api.Client, error) { config := api.Config{ Address: endpoint.Address,