github.com/mailgun/holster/v4@v4.20.0/discovery/consul.go (about)

     1  package discovery
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"reflect"
     7  	"sort"
     8  	"time"
     9  
    10  	"github.com/hashicorp/consul/api"
    11  	"github.com/hashicorp/consul/api/watch"
    12  	"github.com/mailgun/holster/v4/cancel"
    13  	"github.com/mailgun/holster/v4/consul"
    14  	"github.com/mailgun/holster/v4/errors"
    15  	"github.com/mailgun/holster/v4/setter"
    16  	"github.com/mailgun/holster/v4/syncutil"
    17  	"github.com/sirupsen/logrus"
    18  )
    19  
    20  type ConsulConfig struct {
    21  	// This is the consul client config; typically created by calling api.DefaultConfig()
    22  	ClientConfig *api.Config
    23  
    24  	// The name of the catalog we should register under; should be common to all peers in the catalog
    25  	CatalogName string
    26  
    27  	// Information about this peer which should be shared with all other peers in the catalog
    28  	Peer Peer
    29  
    30  	// This is an address the will be registered with consul so it can preform liveliness checks
    31  	LivelinessAddress string
    32  
    33  	// A callback function which is called when the member list changes
    34  	OnUpdate OnUpdateFunc
    35  
    36  	// An interface through which logging will occur; usually *logrus.Entry
    37  	Logger logrus.FieldLogger
    38  }
    39  
    40  type Consul struct {
    41  	wg     syncutil.WaitGroup
    42  	log    logrus.FieldLogger
    43  	client *api.Client
    44  	plan   *watch.Plan
    45  	conf   *ConsulConfig
    46  	ctx    cancel.Context
    47  }
    48  
    49  func NewConsul(conf *ConsulConfig) (Members, error) {
    50  	setter.SetDefault(&conf.Logger, logrus.WithField("category", "consul-catalog"))
    51  	setter.SetDefault(&conf.ClientConfig, api.DefaultConfig())
    52  	var err error
    53  
    54  	if conf.Peer.ID == "" {
    55  		return nil, errors.New("Peer.ID cannot be empty")
    56  	}
    57  
    58  	if conf.CatalogName == "" {
    59  		return nil, errors.New("CatalogName cannot be empty")
    60  	}
    61  
    62  	cs := Consul{
    63  		ctx:  cancel.New(context.Background()),
    64  		log:  conf.Logger,
    65  		conf: conf,
    66  	}
    67  
    68  	cs.client, err = api.NewClient(cs.conf.ClientConfig)
    69  	if err != nil {
    70  		return nil, errors.Wrap(err, "while creating a new client")
    71  	}
    72  
    73  	// Register ourselves in consul as a member of the cluster
    74  	err = cs.client.Agent().ServiceRegisterOpts(&api.AgentServiceRegistration{
    75  		Name:    conf.CatalogName,
    76  		ID:      conf.Peer.ID,
    77  		Tags:    []string{"scout-bloom"},
    78  		Address: conf.LivelinessAddress,
    79  		Check: &api.AgentServiceCheck{
    80  			DeregisterCriticalServiceAfter: "10m",
    81  			TTL:                            "10s",
    82  		},
    83  		Meta: map[string]string{
    84  			"peer": string(conf.Peer.Metadata),
    85  		},
    86  	}, api.ServiceRegisterOpts{ReplaceExistingChecks: true})
    87  	if err != nil {
    88  		return nil, errors.Wrapf(err, "while registering the peer '%s' to the service catalog '%s'",
    89  			conf.Peer.ID, cs.conf.CatalogName)
    90  	}
    91  
    92  	// Update the service check TTL
    93  	err = cs.client.Agent().UpdateTTL(fmt.Sprintf("service:%s", conf.Peer.ID), "", api.HealthPassing)
    94  	if err != nil {
    95  		return nil, errors.Wrap(err, "while updating service TTL after registration")
    96  	}
    97  
    98  	cs.log.Debugf("Registered '%s' with consul catalog '%s'", conf.Peer.ID, conf.CatalogName)
    99  
   100  	// Periodically update the TTL check on the registered service
   101  	ticker := time.NewTicker(time.Second * 4)
   102  	cs.wg.Until(func(done chan struct{}) bool {
   103  		select {
   104  		case <-ticker.C:
   105  			err := cs.client.Agent().UpdateTTL(fmt.Sprintf("service:%s", conf.Peer.ID), "", api.HealthPassing)
   106  			if err != nil {
   107  				cs.log.WithError(err).Warn("while updating consul TTL")
   108  			}
   109  		case <-done:
   110  			ticker.Stop()
   111  			return false
   112  		}
   113  		return true
   114  	})
   115  
   116  	// Watch for changes to the service list and partition config changes
   117  	if err := cs.watch(); err != nil {
   118  		return nil, err
   119  	}
   120  
   121  	return &cs, nil
   122  }
   123  
   124  func (cs *Consul) watch() error {
   125  	changeCh := make(chan []*api.ServiceEntry, 100)
   126  	var previousPeers map[string]Peer
   127  	var err error
   128  
   129  	cs.plan, err = watch.Parse(map[string]interface{}{
   130  		"type":    "service",
   131  		"service": cs.conf.CatalogName,
   132  	})
   133  	if err != nil {
   134  		return fmt.Errorf("while creating watch plan: %s", err)
   135  	}
   136  
   137  	cs.plan.HybridHandler = func(blockParamVal watch.BlockingParamVal, raw interface{}) {
   138  		if raw == nil {
   139  			cs.log.Info("Raw == nil")
   140  		}
   141  		if v, ok := raw.([]*api.ServiceEntry); ok && v != nil {
   142  			changeCh <- v
   143  		}
   144  	}
   145  
   146  	allChecksPassing := func(checks api.HealthChecks) bool {
   147  		for _, c := range checks {
   148  			if c.Status != "passing" {
   149  				return false
   150  			}
   151  		}
   152  		return true
   153  	}
   154  
   155  	cs.wg.Go(func() {
   156  		if err := cs.plan.RunWithClientAndHclog(cs.client, consul.NewHCLogAdapter(cs.log, "consul-store")); err != nil {
   157  			cs.log.WithError(err).Error("Service watch failed")
   158  		}
   159  	})
   160  
   161  	cs.wg.Until(func(done chan struct{}) bool {
   162  		select {
   163  		case <-done:
   164  			return false
   165  		case serviceEntries := <-changeCh:
   166  			if cs.conf.OnUpdate == nil {
   167  				return true
   168  			}
   169  			peers := make(map[string]Peer)
   170  			for _, se := range serviceEntries {
   171  				if !allChecksPassing(se.Checks) {
   172  					break
   173  				}
   174  				meta, ok := se.Service.Meta["peer"]
   175  				if !ok {
   176  					cs.log.Errorf("service entry missing 'peer' metadata '%s'", se.Service.ID)
   177  				}
   178  				p := Peer{ID: se.Service.ID, Metadata: []byte(meta)}
   179  				if meta == string(cs.conf.Peer.Metadata) {
   180  					p.IsSelf = true
   181  				}
   182  				peers[p.ID] = p
   183  			}
   184  
   185  			if !reflect.DeepEqual(previousPeers, peers) {
   186  				var result []Peer
   187  				for _, v := range peers {
   188  					result = append(result, v)
   189  				}
   190  				// Sort the results to make it easy to compare peer lists
   191  				sort.Slice(result, func(i, j int) bool {
   192  					return result[i].ID < result[j].ID
   193  				})
   194  				cs.conf.OnUpdate(result)
   195  				previousPeers = peers
   196  			}
   197  		}
   198  		return true
   199  	})
   200  	return nil
   201  }
   202  
   203  func (cs *Consul) GetPeers(ctx context.Context) ([]Peer, error) {
   204  	opts := &api.QueryOptions{LocalOnly: true}
   205  	services, _, err := cs.client.Health().Service(cs.conf.CatalogName, "", true, opts.WithContext(ctx))
   206  	if err != nil {
   207  		return nil, errors.Wrap(err, "while fetching healthy catalog listing")
   208  	}
   209  	var peers []Peer
   210  	for _, i := range services {
   211  		v, ok := i.Service.Meta["peer"]
   212  		if !ok {
   213  			return nil, fmt.Errorf("service entry missing 'peer' metadata '%s'", i.Service.ID)
   214  		}
   215  		var p Peer
   216  		p.Metadata = []byte(v)
   217  		p.ID = i.Service.ID
   218  		if v == string(cs.conf.Peer.Metadata) {
   219  			p.IsSelf = true
   220  		}
   221  		peers = append(peers, p)
   222  	}
   223  	return peers, nil
   224  }
   225  
   226  func (cs *Consul) Close(ctx context.Context) error {
   227  	errCh := make(chan error)
   228  	go func() {
   229  		cs.plan.Stop()
   230  		cs.wg.Stop()
   231  		errCh <- cs.client.Agent().ServiceDeregister(cs.conf.Peer.ID)
   232  	}()
   233  
   234  	select {
   235  	case <-ctx.Done():
   236  		cs.ctx.Cancel()
   237  		return ctx.Err()
   238  	case err := <-errCh:
   239  		return err
   240  	}
   241  }