Add Support for Consul Connect
Co-authored-by: Florian Apolloner <apollo13@users.noreply.github.com>
This commit is contained in:
parent
3a180e2afc
commit
7e43e5615e
36 changed files with 2118 additions and 644 deletions
|
@ -4,12 +4,14 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/consul/api/watch"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/sirupsen/logrus"
|
||||
ptypes "github.com/traefik/paerser/types"
|
||||
"github.com/traefik/traefik/v2/pkg/config/dynamic"
|
||||
"github.com/traefik/traefik/v2/pkg/job"
|
||||
|
@ -26,15 +28,17 @@ const DefaultTemplateRule = "Host(`{{ normalize .Name }}`)"
|
|||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
type itemData struct {
|
||||
ID string
|
||||
Node string
|
||||
Name string
|
||||
Address string
|
||||
Port string
|
||||
Status string
|
||||
Labels map[string]string
|
||||
Tags []string
|
||||
ExtraConf configuration
|
||||
ID string
|
||||
Node string
|
||||
Datacenter string
|
||||
Name string
|
||||
Namespace string
|
||||
Address string
|
||||
Port string
|
||||
Status string
|
||||
Labels map[string]string
|
||||
Tags []string
|
||||
ExtraConf configuration
|
||||
}
|
||||
|
||||
// Provider holds configurations of the provider.
|
||||
|
@ -48,9 +52,13 @@ type Provider struct {
|
|||
Cache bool `description:"Use local agent caching for catalog reads." json:"cache,omitempty" toml:"cache,omitempty" yaml:"cache,omitempty" export:"true"`
|
||||
ExposedByDefault bool `description:"Expose containers by default." json:"exposedByDefault,omitempty" toml:"exposedByDefault,omitempty" yaml:"exposedByDefault,omitempty" export:"true"`
|
||||
DefaultRule string `description:"Default rule." json:"defaultRule,omitempty" toml:"defaultRule,omitempty" yaml:"defaultRule,omitempty"`
|
||||
ConnectAware bool `description:"Enable Consul Connect support." json:"connectAware,omitempty" toml:"connectAware,omitempty" yaml:"connectAware,omitempty" export:"true"`
|
||||
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"`
|
||||
|
||||
client *api.Client
|
||||
defaultRuleTpl *template.Template
|
||||
certChan chan *connectCert
|
||||
}
|
||||
|
||||
// EndpointConfig holds configurations of the endpoint.
|
||||
|
@ -64,11 +72,6 @@ type EndpointConfig struct {
|
|||
EndpointWaitTime ptypes.Duration `description:"WaitTime limits how long a Watch will block. If not provided, the agent default values will be used" json:"endpointWaitTime,omitempty" toml:"endpointWaitTime,omitempty" yaml:"endpointWaitTime,omitempty" export:"true"`
|
||||
}
|
||||
|
||||
// SetDefaults sets the default values.
|
||||
func (c *EndpointConfig) SetDefaults() {
|
||||
c.Address = "127.0.0.1:8500"
|
||||
}
|
||||
|
||||
// EndpointHTTPAuthConfig holds configurations of the authentication.
|
||||
type EndpointHTTPAuthConfig struct {
|
||||
Username string `description:"Basic Auth username" json:"username,omitempty" toml:"username,omitempty" yaml:"username,omitempty"`
|
||||
|
@ -78,12 +81,13 @@ type EndpointHTTPAuthConfig struct {
|
|||
// SetDefaults sets the default values.
|
||||
func (p *Provider) SetDefaults() {
|
||||
endpoint := &EndpointConfig{}
|
||||
endpoint.SetDefaults()
|
||||
p.Endpoint = endpoint
|
||||
p.RefreshInterval = ptypes.Duration(15 * time.Second)
|
||||
p.Prefix = "traefik"
|
||||
p.ExposedByDefault = true
|
||||
p.DefaultRule = DefaultTemplateRule
|
||||
p.ServiceName = "traefik"
|
||||
p.certChan = make(chan *connectCert)
|
||||
}
|
||||
|
||||
// Init the provider.
|
||||
|
@ -99,6 +103,24 @@ func (p *Provider) Init() error {
|
|||
|
||||
// Provide allows the consul catalog provider to provide configurations to traefik using the given configuration channel.
|
||||
func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error {
|
||||
var err error
|
||||
p.client, err = createClient(p.Endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create consul client: %w", err)
|
||||
}
|
||||
|
||||
if p.ConnectAware {
|
||||
leafWatcher, rootWatcher, err := p.createConnectTLSWatchers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create consul watch plans: %w", err)
|
||||
}
|
||||
pool.GoCtx(func(routineCtx context.Context) {
|
||||
p.watchConnectTLS(routineCtx, leafWatcher, rootWatcher)
|
||||
})
|
||||
}
|
||||
|
||||
var certInfo *connectCert
|
||||
|
||||
pool.GoCtx(func(routineCtx context.Context) {
|
||||
ctxLog := log.With(routineCtx, log.Str(log.ProviderName, "consulcatalog"))
|
||||
logger := log.FromContext(ctxLog)
|
||||
|
@ -106,13 +128,25 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
|
|||
operation := func() error {
|
||||
var err error
|
||||
|
||||
p.client, err = createClient(p.Endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create consul client: %w", err)
|
||||
// If we are running in connect aware mode then we need to
|
||||
// make sure that we obtain the certificates before starting
|
||||
// the service watcher, otherwise a connect enabled service
|
||||
// that gets resolved before the certificates are available
|
||||
// will cause an error condition.
|
||||
if p.ConnectAware && !certInfo.isReady() {
|
||||
logger.Infof("Waiting for Connect certificate before building first configuration")
|
||||
select {
|
||||
case <-routineCtx.Done():
|
||||
return nil
|
||||
case certInfo = <-p.certChan:
|
||||
if certInfo.err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get configuration at the provider's startup.
|
||||
err = p.loadConfiguration(routineCtx, configurationChan)
|
||||
err = p.loadConfiguration(ctxLog, certInfo, configurationChan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get consul catalog data: %w", err)
|
||||
}
|
||||
|
@ -123,14 +157,17 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
|
|||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err = p.loadConfiguration(routineCtx, configurationChan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh consul catalog data: %w", err)
|
||||
}
|
||||
|
||||
case <-routineCtx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
case certInfo = <-p.certChan:
|
||||
if certInfo.err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
}
|
||||
err = p.loadConfiguration(ctxLog, certInfo, configurationChan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh consul catalog data: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -148,7 +185,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) loadConfiguration(ctx context.Context, configurationChan chan<- dynamic.Message) error {
|
||||
func (p *Provider) loadConfiguration(ctx context.Context, certInfo *connectCert, configurationChan chan<- dynamic.Message) error {
|
||||
data, err := p.getConsulServicesData(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -156,21 +193,53 @@ func (p *Provider) loadConfiguration(ctx context.Context, configurationChan chan
|
|||
|
||||
configurationChan <- dynamic.Message{
|
||||
ProviderName: "consulcatalog",
|
||||
Configuration: p.buildConfiguration(ctx, data),
|
||||
Configuration: p.buildConfiguration(ctx, data, certInfo),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) getConsulServicesData(ctx context.Context) ([]itemData, error) {
|
||||
consulServiceNames, err := p.fetchServices(ctx)
|
||||
// The query option "Filter" is not supported by /catalog/services.
|
||||
// https://www.consul.io/api/catalog.html#list-services
|
||||
opts := &api.QueryOptions{AllowStale: p.Stale, RequireConsistent: p.RequireConsistent, UseCache: p.Cache}
|
||||
serviceNames, _, err := p.client.Catalog().Services(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data []itemData
|
||||
for _, name := range consulServiceNames {
|
||||
consulServices, statuses, err := p.fetchService(ctx, name)
|
||||
for name, tags := range serviceNames {
|
||||
logger := log.FromContext(log.With(ctx, log.Str("serviceName", name)))
|
||||
|
||||
svcCfg, err := p.getConfiguration(tagsToNeutralLabels(tags, p.Prefix))
|
||||
if err != nil {
|
||||
logger.Errorf("Skip service: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !svcCfg.Enable {
|
||||
logger.Debug("Filtering disabled item")
|
||||
continue
|
||||
}
|
||||
|
||||
matches, err := constraints.MatchTags(tags, p.Constraints)
|
||||
if err != nil {
|
||||
logger.Errorf("Error matching constraint expressions: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !matches {
|
||||
logger.Debugf("Container pruned by constraint expressions: %q", p.Constraints)
|
||||
continue
|
||||
}
|
||||
|
||||
if !p.ConnectAware && svcCfg.ConsulCatalog.Connect {
|
||||
logger.Debugf("Filtering out Connect aware item, Connect support is not enabled")
|
||||
continue
|
||||
}
|
||||
|
||||
consulServices, statuses, err := p.fetchService(ctx, name, svcCfg.ConsulCatalog.Connect)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -181,23 +250,30 @@ func (p *Provider) getConsulServicesData(ctx context.Context) ([]itemData, error
|
|||
address = consulService.Address
|
||||
}
|
||||
|
||||
namespace := consulService.Namespace
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
status, exists := statuses[consulService.ID+consulService.ServiceID]
|
||||
if !exists {
|
||||
status = api.HealthAny
|
||||
}
|
||||
|
||||
item := itemData{
|
||||
ID: consulService.ServiceID,
|
||||
Node: consulService.Node,
|
||||
Name: consulService.ServiceName,
|
||||
Address: address,
|
||||
Port: strconv.Itoa(consulService.ServicePort),
|
||||
Labels: tagsToNeutralLabels(consulService.ServiceTags, p.Prefix),
|
||||
Tags: consulService.ServiceTags,
|
||||
Status: status,
|
||||
ID: consulService.ServiceID,
|
||||
Node: consulService.Node,
|
||||
Datacenter: consulService.Datacenter,
|
||||
Namespace: namespace,
|
||||
Name: name,
|
||||
Address: address,
|
||||
Port: strconv.Itoa(consulService.ServicePort),
|
||||
Labels: tagsToNeutralLabels(consulService.ServiceTags, p.Prefix),
|
||||
Tags: consulService.ServiceTags,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
extraConf, err := p.getConfiguration(item)
|
||||
extraConf, err := p.getConfiguration(item.Labels)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Skip item %s: %v", item.Name, err)
|
||||
continue
|
||||
|
@ -207,10 +283,11 @@ func (p *Provider) getConsulServicesData(ctx context.Context) ([]itemData, error
|
|||
data = append(data, item)
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) fetchService(ctx context.Context, name string) ([]*api.CatalogService, map[string]string, error) {
|
||||
func (p *Provider) fetchService(ctx context.Context, name string, connectEnabled bool) ([]*api.CatalogService, map[string]string, error) {
|
||||
var tagFilter string
|
||||
if !p.ExposedByDefault {
|
||||
tagFilter = p.Prefix + ".enable=true"
|
||||
|
@ -219,12 +296,19 @@ func (p *Provider) fetchService(ctx context.Context, name string) ([]*api.Catalo
|
|||
opts := &api.QueryOptions{AllowStale: p.Stale, RequireConsistent: p.RequireConsistent, UseCache: p.Cache}
|
||||
opts = opts.WithContext(ctx)
|
||||
|
||||
consulServices, _, err := p.client.Catalog().Service(name, tagFilter, opts)
|
||||
catalogFunc := p.client.Catalog().Service
|
||||
healthFunc := p.client.Health().Service
|
||||
if connectEnabled {
|
||||
catalogFunc = p.client.Catalog().Connect
|
||||
healthFunc = p.client.Health().Connect
|
||||
}
|
||||
|
||||
consulServices, _, err := catalogFunc(name, tagFilter, opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
healthServices, _, err := p.client.Health().Service(name, tagFilter, false, opts)
|
||||
healthServices, _, err := healthFunc(name, tagFilter, false, opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@ -243,55 +327,132 @@ func (p *Provider) fetchService(ctx context.Context, name string) ([]*api.Catalo
|
|||
return consulServices, statuses, err
|
||||
}
|
||||
|
||||
func (p *Provider) fetchServices(ctx context.Context) ([]string, error) {
|
||||
// The query option "Filter" is not supported by /catalog/services.
|
||||
// https://www.consul.io/api/catalog.html#list-services
|
||||
opts := &api.QueryOptions{AllowStale: p.Stale, RequireConsistent: p.RequireConsistent, UseCache: p.Cache}
|
||||
serviceNames, _, err := p.client.Catalog().Services(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func rootsWatchHandler(ctx context.Context, dest chan<- []string) func(watch.BlockingParamVal, interface{}) {
|
||||
return func(_ watch.BlockingParamVal, raw interface{}) {
|
||||
if raw == nil {
|
||||
log.FromContext(ctx).Errorf("Root certificate watcher called with nil")
|
||||
return
|
||||
}
|
||||
|
||||
v, ok := raw.(*api.CARootList)
|
||||
if !ok || v == nil {
|
||||
log.FromContext(ctx).Errorf("Invalid result for root certificate watcher")
|
||||
return
|
||||
}
|
||||
|
||||
roots := make([]string, 0, len(v.Roots))
|
||||
for _, root := range v.Roots {
|
||||
roots = append(roots, root.RootCertPEM)
|
||||
}
|
||||
|
||||
dest <- roots
|
||||
}
|
||||
|
||||
// The keys are the service names, and the array values provide all known tags for a given service.
|
||||
// https://www.consul.io/api/catalog.html#list-services
|
||||
var filtered []string
|
||||
for svcName, tags := range serviceNames {
|
||||
logger := log.FromContext(log.With(ctx, log.Str("serviceName", svcName)))
|
||||
|
||||
if !p.ExposedByDefault && !contains(tags, p.Prefix+".enable=true") {
|
||||
logger.Debug("Filtering disabled item")
|
||||
continue
|
||||
}
|
||||
|
||||
if contains(tags, p.Prefix+".enable=false") {
|
||||
logger.Debug("Filtering disabled item")
|
||||
continue
|
||||
}
|
||||
|
||||
matches, err := constraints.MatchTags(tags, p.Constraints)
|
||||
if err != nil {
|
||||
logger.Errorf("Error matching constraints expression: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !matches {
|
||||
logger.Debugf("Container pruned by constraint expression: %q", p.Constraints)
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, svcName)
|
||||
}
|
||||
|
||||
return filtered, err
|
||||
}
|
||||
|
||||
func contains(values []string, val string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, val) {
|
||||
return true
|
||||
type keyPair struct {
|
||||
cert string
|
||||
key string
|
||||
}
|
||||
|
||||
func leafWatcherHandler(ctx context.Context, dest chan<- keyPair) func(watch.BlockingParamVal, interface{}) {
|
||||
return func(_ watch.BlockingParamVal, raw interface{}) {
|
||||
if raw == nil {
|
||||
log.FromContext(ctx).Errorf("Leaf certificate watcher called with nil")
|
||||
return
|
||||
}
|
||||
|
||||
v, ok := raw.(*api.LeafCert)
|
||||
if !ok || v == nil {
|
||||
log.FromContext(ctx).Errorf("Invalid result for leaf certificate watcher")
|
||||
return
|
||||
}
|
||||
|
||||
dest <- keyPair{
|
||||
cert: v.CertPEM,
|
||||
key: v.PrivateKeyPEM,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) createConnectTLSWatchers() (*watch.Plan, *watch.Plan, error) {
|
||||
leafWatcher, err := watch.Parse(map[string]interface{}{
|
||||
"type": "connect_leaf",
|
||||
"service": p.ServiceName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create leaf cert watcher plan: %w", err)
|
||||
}
|
||||
|
||||
rootWatcher, err := watch.Parse(map[string]interface{}{
|
||||
"type": "connect_roots",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create root cert watcher plan: %w", err)
|
||||
}
|
||||
|
||||
return leafWatcher, rootWatcher, nil
|
||||
}
|
||||
|
||||
// watchConnectTLS watches for updates of the root certificate or the leaf
|
||||
// certificate, and transmits them to the caller via p.certChan. Any error is also
|
||||
// propagated up through p.certChan, in connectCert.err.
|
||||
func (p *Provider) watchConnectTLS(ctx context.Context, leafWatcher *watch.Plan, rootWatcher *watch.Plan) {
|
||||
ctxLog := log.With(ctx, log.Str(log.ProviderName, "consulcatalog"))
|
||||
logger := log.FromContext(ctxLog)
|
||||
|
||||
leafChan := make(chan keyPair)
|
||||
rootChan := make(chan []string)
|
||||
|
||||
leafWatcher.HybridHandler = leafWatcherHandler(ctx, leafChan)
|
||||
rootWatcher.HybridHandler = rootsWatchHandler(ctx, rootChan)
|
||||
|
||||
logOpts := &hclog.LoggerOptions{
|
||||
Name: "consulcatalog",
|
||||
Level: hclog.LevelFromString(logrus.GetLevel().String()),
|
||||
JSONFormat: true,
|
||||
}
|
||||
|
||||
hclogger := hclog.New(logOpts)
|
||||
|
||||
go func() {
|
||||
err := leafWatcher.RunWithClientAndHclog(p.client, hclogger)
|
||||
if err != nil {
|
||||
p.certChan <- &connectCert{err: err}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
err := rootWatcher.RunWithClientAndHclog(p.client, hclogger)
|
||||
if err != nil {
|
||||
p.certChan <- &connectCert{err: err}
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
certInfo *connectCert
|
||||
leafCerts keyPair
|
||||
rootCerts []string
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
leafWatcher.Stop()
|
||||
rootWatcher.Stop()
|
||||
return
|
||||
case rootCerts = <-rootChan:
|
||||
case leafCerts = <-leafChan:
|
||||
}
|
||||
newCertInfo := &connectCert{
|
||||
root: rootCerts,
|
||||
leaf: leafCerts,
|
||||
}
|
||||
if newCertInfo.isReady() && !newCertInfo.equals(certInfo) {
|
||||
logger.Debugf("Updating connect certs for service %s", p.ServiceName)
|
||||
certInfo = newCertInfo
|
||||
p.certChan <- newCertInfo
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func createClient(cfg *EndpointConfig) (*api.Client, error) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue