diff --git a/broker/broker.go b/broker/broker.go index 33b97eb8..690da409 100644 --- a/broker/broker.go +++ b/broker/broker.go @@ -41,7 +41,7 @@ type Subscriber interface { var ( // DefaultBroker is the default Broker. - DefaultBroker = NewBroker() + DefaultBroker = NewMemoryBroker() ) func Init(opts ...Option) error { diff --git a/broker/http.go b/broker/http/http.go similarity index 92% rename from broker/http.go rename to broker/http/http.go index def6993e..ac541402 100644 --- a/broker/http.go +++ b/broker/http/http.go @@ -1,5 +1,5 @@ -// Package broker provides a http based message broker -package broker +// Package http provides a http based message broker +package http import ( "bytes" @@ -16,6 +16,7 @@ import ( "time" "github.com/google/uuid" + "go-micro.dev/v5/broker" "go-micro.dev/v5/codec/json" merr "go-micro.dev/v5/errors" "go-micro.dev/v5/registry" @@ -29,7 +30,7 @@ import ( // HTTP Broker is a point to point async broker. type httpBroker struct { - opts Options + opts broker.Options r registry.Registry @@ -51,8 +52,8 @@ type httpBroker struct { } type httpSubscriber struct { - opts SubscribeOptions - fn Handler + opts broker.SubscribeOptions + fn broker.Handler svc *registry.Service hb *httpBroker id string @@ -61,7 +62,7 @@ type httpSubscriber struct { type httpEvent struct { err error - m *Message + m *broker.Message t string } @@ -108,8 +109,8 @@ func newTransport(config *tls.Config) *http.Transport { return t } -func newHttpBroker(opts ...Option) Broker { - options := *NewOptions(opts...) +func newHttpBroker(opts ...broker.Option) broker.Broker { + options := *broker.NewOptions(opts...) options.Registry = registry.DefaultRegistry options.Codec = json.Marshaler{} @@ -161,7 +162,7 @@ func (h *httpEvent) Error() error { return h.err } -func (h *httpEvent) Message() *Message { +func (h *httpEvent) Message() *broker.Message { return h.m } @@ -169,7 +170,7 @@ func (h *httpEvent) Topic() string { return h.t } -func (h *httpSubscriber) Options() SubscribeOptions { +func (h *httpSubscriber) Options() broker.SubscribeOptions { return h.opts } @@ -308,7 +309,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - var m *Message + var m *broker.Message if err = h.opts.Codec.Unmarshal(b, &m); err != nil { errr := merr.InternalServerError("go.micro.broker", "Error parsing request body: %v", err) w.WriteHeader(500) @@ -330,7 +331,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) { id := req.Form.Get("id") //nolint:prealloc - var subs []Handler + var subs []broker.Handler h.RLock() for _, subscriber := range h.subscribers[topic] { @@ -458,7 +459,7 @@ func (h *httpBroker) Disconnect() error { return err } -func (h *httpBroker) Init(opts ...Option) error { +func (h *httpBroker) Init(opts ...broker.Option) error { h.RLock() if h.running { h.RUnlock() @@ -505,13 +506,13 @@ func (h *httpBroker) Init(opts ...Option) error { return nil } -func (h *httpBroker) Options() Options { +func (h *httpBroker) Options() broker.Options { return h.opts } -func (h *httpBroker) Publish(topic string, msg *Message, opts ...PublishOption) error { +func (h *httpBroker) Publish(topic string, msg *broker.Message, opts ...broker.PublishOption) error { // create the message first - m := &Message{ + m := &broker.Message{ Header: make(map[string]string), Body: msg.Body, } @@ -637,10 +638,10 @@ func (h *httpBroker) Publish(topic string, msg *Message, opts ...PublishOption) return nil } -func (h *httpBroker) Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Subscriber, error) { +func (h *httpBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { var err error var host, port string - options := NewSubscribeOptions(opts...) + options := broker.NewSubscribeOptions(opts...) // parse address for host, port host, port, err = net.SplitHostPort(h.Address()) @@ -705,7 +706,7 @@ func (h *httpBroker) String() string { return "http" } -// NewBroker returns a new http broker. -func NewBroker(opts ...Option) Broker { +// NewHttpBroker returns a new http broker. +func NewHttpBroker(opts ...broker.Option) broker.Broker { return newHttpBroker(opts...) } diff --git a/broker/http_test.go b/broker/http/http_test.go similarity index 96% rename from broker/http_test.go rename to broker/http/http_test.go index 71c984e7..bff3f577 100644 --- a/broker/http_test.go +++ b/broker/http/http_test.go @@ -1,4 +1,4 @@ -package broker_test +package http_test import ( "sync" @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "go-micro.dev/v5/broker" + "go-micro.dev/v5/broker/http" "go-micro.dev/v5/registry" ) @@ -60,7 +61,7 @@ func sub(b *testing.B, c int) { b.StopTimer() m := newTestRegistry() - brker := broker.NewBroker(broker.Registry(m)) + brker := http.NewHttpBroker(broker.Registry(m)) topic := uuid.New().String() if err := brker.Init(); err != nil { @@ -121,7 +122,7 @@ func sub(b *testing.B, c int) { func pub(b *testing.B, c int) { b.StopTimer() m := newTestRegistry() - brk := broker.NewBroker(broker.Registry(m)) + brk := http.NewHttpBroker(broker.Registry(m)) topic := uuid.New().String() if err := brk.Init(); err != nil { @@ -190,7 +191,7 @@ func pub(b *testing.B, c int) { func TestBroker(t *testing.T) { m := newTestRegistry() - b := broker.NewBroker(broker.Registry(m)) + b := http.NewHttpBroker(broker.Registry(m)) if err := b.Init(); err != nil { t.Fatalf("Unexpected init error: %v", err) @@ -239,7 +240,7 @@ func TestBroker(t *testing.T) { func TestConcurrentSubBroker(t *testing.T) { m := newTestRegistry() - b := broker.NewBroker(broker.Registry(m)) + b := http.NewHttpBroker(broker.Registry(m)) if err := b.Init(); err != nil { t.Fatalf("Unexpected init error: %v", err) @@ -298,7 +299,7 @@ func TestConcurrentSubBroker(t *testing.T) { func TestConcurrentPubBroker(t *testing.T) { m := newTestRegistry() - b := broker.NewBroker(broker.Registry(m)) + b := http.NewHttpBroker(broker.Registry(m)) if err := b.Init(); err != nil { t.Fatalf("Unexpected init error: %v", err) diff --git a/broker/nats/context.go b/broker/nats/context.go new file mode 100644 index 00000000..e73ba1a1 --- /dev/null +++ b/broker/nats/context.go @@ -0,0 +1,17 @@ +package nats + +import ( + "context" + + "go-micro.dev/v5/broker" +) + +// setBrokerOption returns a function to setup a context with given value. +func setBrokerOption(k, v interface{}) broker.Option { + return func(o *broker.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, k, v) + } +} diff --git a/broker/nats/nats.go b/broker/nats/nats.go new file mode 100644 index 00000000..73425567 --- /dev/null +++ b/broker/nats/nats.go @@ -0,0 +1,315 @@ +// Package nats provides a NATS broker +package nats + +import ( + "context" + "errors" + "strings" + "sync" + + natsp "github.com/nats-io/nats.go" + "go-micro.dev/v5/broker" + "go-micro.dev/v5/codec/json" + "go-micro.dev/v5/logger" + "go-micro.dev/v5/registry" +) + +type natsBroker struct { + sync.Once + sync.RWMutex + + // indicate if we're connected + connected bool + + addrs []string + conn *natsp.Conn + opts broker.Options + nopts natsp.Options + + // should we drain the connection + drain bool + closeCh chan (error) +} + +type subscriber struct { + s *natsp.Subscription + opts broker.SubscribeOptions +} + +type publication struct { + t string + err error + m *broker.Message +} + +func (p *publication) Topic() string { + return p.t +} + +func (p *publication) Message() *broker.Message { + return p.m +} + +func (p *publication) Ack() error { + // nats does not support acking + return nil +} + +func (p *publication) Error() error { + return p.err +} + +func (s *subscriber) Options() broker.SubscribeOptions { + return s.opts +} + +func (s *subscriber) Topic() string { + return s.s.Subject +} + +func (s *subscriber) Unsubscribe() error { + return s.s.Unsubscribe() +} + +func (n *natsBroker) Address() string { + if n.conn != nil && n.conn.IsConnected() { + return n.conn.ConnectedUrl() + } + + if len(n.addrs) > 0 { + return n.addrs[0] + } + + return "" +} + +func (n *natsBroker) setAddrs(addrs []string) []string { + //nolint:prealloc + var cAddrs []string + for _, addr := range addrs { + if len(addr) == 0 { + continue + } + if !strings.HasPrefix(addr, "nats://") { + addr = "nats://" + addr + } + cAddrs = append(cAddrs, addr) + } + if len(cAddrs) == 0 { + cAddrs = []string{natsp.DefaultURL} + } + return cAddrs +} + +func (n *natsBroker) Connect() error { + n.Lock() + defer n.Unlock() + + if n.connected { + return nil + } + + status := natsp.CLOSED + if n.conn != nil { + status = n.conn.Status() + } + + switch status { + case natsp.CONNECTED, natsp.RECONNECTING, natsp.CONNECTING: + n.connected = true + return nil + default: // DISCONNECTED or CLOSED or DRAINING + opts := n.nopts + opts.Servers = n.addrs + opts.Secure = n.opts.Secure + opts.TLSConfig = n.opts.TLSConfig + + // secure might not be set + if n.opts.TLSConfig != nil { + opts.Secure = true + } + + c, err := opts.Connect() + if err != nil { + return err + } + n.conn = c + n.connected = true + return nil + } +} + +func (n *natsBroker) Disconnect() error { + n.Lock() + defer n.Unlock() + + // drain the connection if specified + if n.drain { + n.conn.Drain() + n.closeCh <- nil + } + + // close the client connection + n.conn.Close() + + // set not connected + n.connected = false + + return nil +} + +func (n *natsBroker) Init(opts ...broker.Option) error { + n.setOption(opts...) + return nil +} + +func (n *natsBroker) Options() broker.Options { + return n.opts +} + +func (n *natsBroker) Publish(topic string, msg *broker.Message, opts ...broker.PublishOption) error { + n.RLock() + defer n.RUnlock() + + if n.conn == nil { + return errors.New("not connected") + } + + b, err := n.opts.Codec.Marshal(msg) + if err != nil { + return err + } + return n.conn.Publish(topic, b) +} + +func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...broker.SubscribeOption) (broker.Subscriber, error) { + n.RLock() + if n.conn == nil { + n.RUnlock() + return nil, errors.New("not connected") + } + n.RUnlock() + + opt := broker.SubscribeOptions{ + AutoAck: true, + Context: context.Background(), + } + + for _, o := range opts { + o(&opt) + } + + fn := func(msg *natsp.Msg) { + var m broker.Message + pub := &publication{t: msg.Subject} + eh := n.opts.ErrorHandler + err := n.opts.Codec.Unmarshal(msg.Data, &m) + pub.err = err + pub.m = &m + if err != nil { + m.Body = msg.Data + n.opts.Logger.Log(logger.ErrorLevel, err) + if eh != nil { + eh(pub) + } + return + } + if err := handler(pub); err != nil { + pub.err = err + n.opts.Logger.Log(logger.ErrorLevel, err) + if eh != nil { + eh(pub) + } + } + } + + var sub *natsp.Subscription + var err error + + n.RLock() + if len(opt.Queue) > 0 { + sub, err = n.conn.QueueSubscribe(topic, opt.Queue, fn) + } else { + sub, err = n.conn.Subscribe(topic, fn) + } + n.RUnlock() + if err != nil { + return nil, err + } + return &subscriber{s: sub, opts: opt}, nil +} + +func (n *natsBroker) String() string { + return "nats" +} + +func (n *natsBroker) setOption(opts ...broker.Option) { + for _, o := range opts { + o(&n.opts) + } + + n.Once.Do(func() { + n.nopts = natsp.GetDefaultOptions() + }) + + if nopts, ok := n.opts.Context.Value(optionsKey{}).(natsp.Options); ok { + n.nopts = nopts + } + + // broker.Options have higher priority than nats.Options + // only if Addrs, Secure or TLSConfig were not set through a broker.Option + // we read them from nats.Option + if len(n.opts.Addrs) == 0 { + n.opts.Addrs = n.nopts.Servers + } + + if !n.opts.Secure { + n.opts.Secure = n.nopts.Secure + } + + if n.opts.TLSConfig == nil { + n.opts.TLSConfig = n.nopts.TLSConfig + } + n.addrs = n.setAddrs(n.opts.Addrs) + + if n.opts.Context.Value(drainConnectionKey{}) != nil { + n.drain = true + n.closeCh = make(chan error) + n.nopts.ClosedCB = n.onClose + n.nopts.AsyncErrorCB = n.onAsyncError + n.nopts.DisconnectedErrCB = n.onDisconnectedError + } +} + +func (n *natsBroker) onClose(conn *natsp.Conn) { + n.closeCh <- nil +} + +func (n *natsBroker) onAsyncError(conn *natsp.Conn, sub *natsp.Subscription, err error) { + // There are kinds of different async error nats might callback, but we are interested + // in ErrDrainTimeout only here. + if err == natsp.ErrDrainTimeout { + n.closeCh <- err + } +} + +func (n *natsBroker) onDisconnectedError(conn *natsp.Conn, err error) { + n.closeCh <- err +} + +func NewNatsBroker(opts ...broker.Option) broker.Broker { + options := broker.Options{ + // Default codec + Codec: json.Marshaler{}, + Context: context.Background(), + Registry: registry.DefaultRegistry, + Logger: logger.DefaultLogger, + } + + n := &natsBroker{ + opts: options, + } + n.setOption(opts...) + + return n +} diff --git a/broker/nats/nats_test.go b/broker/nats/nats_test.go new file mode 100644 index 00000000..408bd2a5 --- /dev/null +++ b/broker/nats/nats_test.go @@ -0,0 +1,96 @@ +package nats + +import ( + "fmt" + "testing" + + natsp "github.com/nats-io/nats.go" + "go-micro.dev/v5/broker" +) + +var addrTestCases = []struct { + name string + description string + addrs map[string]string // expected address : set address +}{ + { + "brokerOpts", + "set broker addresses through a broker.Option in constructor", + map[string]string{ + "nats://192.168.10.1:5222": "192.168.10.1:5222", + "nats://10.20.10.0:4222": "10.20.10.0:4222"}, + }, + { + "brokerInit", + "set broker addresses through a broker.Option in broker.Init()", + map[string]string{ + "nats://192.168.10.1:5222": "192.168.10.1:5222", + "nats://10.20.10.0:4222": "10.20.10.0:4222"}, + }, + { + "natsOpts", + "set broker addresses through the nats.Option in constructor", + map[string]string{ + "nats://192.168.10.1:5222": "192.168.10.1:5222", + "nats://10.20.10.0:4222": "10.20.10.0:4222"}, + }, + { + "default", + "check if default Address is set correctly", + map[string]string{ + "nats://127.0.0.1:4222": "", + }, + }, +} + +// TestInitAddrs tests issue #100. Ensures that if the addrs is set by an option in init it will be used. +func TestInitAddrs(t *testing.T) { + for _, tc := range addrTestCases { + t.Run(fmt.Sprintf("%s: %s", tc.name, tc.description), func(t *testing.T) { + var br broker.Broker + var addrs []string + + for _, addr := range tc.addrs { + addrs = append(addrs, addr) + } + + switch tc.name { + case "brokerOpts": + // we know that there are just two addrs in the dict + br = NewNatsBroker(broker.Addrs(addrs[0], addrs[1])) + br.Init() + case "brokerInit": + br = NewNatsBroker() + // we know that there are just two addrs in the dict + br.Init(broker.Addrs(addrs[0], addrs[1])) + case "natsOpts": + nopts := natsp.GetDefaultOptions() + nopts.Servers = addrs + br = NewNatsBroker(Options(nopts)) + br.Init() + case "default": + br = NewNatsBroker() + br.Init() + } + + natsBroker, ok := br.(*natsBroker) + if !ok { + t.Fatal("Expected broker to be of types *natsBroker") + } + // check if the same amount of addrs we set has actually been set, default + // have only 1 address nats://127.0.0.1:4222 (current nats code) or + // nats://localhost:4222 (older code version) + if len(natsBroker.addrs) != len(tc.addrs) && tc.name != "default" { + t.Errorf("Expected Addr count = %d, Actual Addr count = %d", + len(natsBroker.addrs), len(tc.addrs)) + } + + for _, addr := range natsBroker.addrs { + _, ok := tc.addrs[addr] + if !ok { + t.Errorf("Expected '%s' has not been set", addr) + } + } + }) + } +} diff --git a/broker/nats/options.go b/broker/nats/options.go new file mode 100644 index 00000000..e174b009 --- /dev/null +++ b/broker/nats/options.go @@ -0,0 +1,19 @@ +package nats + +import ( + natsp "github.com/nats-io/nats.go" + "go-micro.dev/v5/broker" +) + +type optionsKey struct{} +type drainConnectionKey struct{} + +// Options accepts nats.Options. +func Options(opts natsp.Options) broker.Option { + return setBrokerOption(optionsKey{}, opts) +} + +// DrainConnection will drain subscription on close. +func DrainConnection() broker.Option { + return setBrokerOption(drainConnectionKey{}, struct{}{}) +} diff --git a/cache/redis/options.go b/cache/redis/options.go new file mode 100644 index 00000000..deeba793 --- /dev/null +++ b/cache/redis/options.go @@ -0,0 +1,48 @@ +package redis + +import ( + "context" + + rclient "github.com/go-redis/redis/v8" + "go-micro.dev/v5/cache" +) + +type redisOptionsContextKey struct{} + +// WithRedisOptions sets advanced options for redis. +func WithRedisOptions(options rclient.UniversalOptions) cache.Option { + return func(o *cache.Options) { + if o.Context == nil { + o.Context = context.Background() + } + + o.Context = context.WithValue(o.Context, redisOptionsContextKey{}, options) + } +} + +func newUniversalClient(options cache.Options) rclient.UniversalClient { + if options.Context == nil { + options.Context = context.Background() + } + + opts, ok := options.Context.Value(redisOptionsContextKey{}).(rclient.UniversalOptions) + if !ok { + addr := "redis://127.0.0.1:6379" + if len(options.Address) > 0 { + addr = options.Address + } + + redisOptions, err := rclient.ParseURL(addr) + if err != nil { + redisOptions = &rclient.Options{Addr: addr} + } + + return rclient.NewClient(redisOptions) + } + + if len(opts.Addrs) == 0 && len(options.Address) > 0 { + opts.Addrs = []string{options.Address} + } + + return rclient.NewUniversalClient(&opts) +} diff --git a/cache/redis/options_test.go b/cache/redis/options_test.go new file mode 100644 index 00000000..0eeca878 --- /dev/null +++ b/cache/redis/options_test.go @@ -0,0 +1,139 @@ +package redis + +import ( + "context" + "reflect" + "testing" + + rclient "github.com/go-redis/redis/v8" + "go-micro.dev/v5/cache" +) + +func Test_newUniversalClient(t *testing.T) { + type fields struct { + options cache.Options + } + type wantValues struct { + username string + password string + address string + } + + tests := []struct { + name string + fields fields + want wantValues + }{ + {name: "No Url", fields: fields{options: cache.Options{}}, + want: wantValues{ + username: "", + password: "", + address: "127.0.0.1:6379", + }}, + {name: "legacy Url", fields: fields{options: cache.Options{Address: "127.0.0.1:6379"}}, + want: wantValues{ + username: "", + password: "", + address: "127.0.0.1:6379", + }}, + {name: "New Url", fields: fields{options: cache.Options{Address: "redis://127.0.0.1:6379"}}, + want: wantValues{ + username: "", + password: "", + address: "127.0.0.1:6379", + }}, + {name: "Url with Pwd", fields: fields{options: cache.Options{Address: "redis://:password@redis:6379"}}, + want: wantValues{ + username: "", + password: "password", + address: "redis:6379", + }}, + {name: "Url with username and Pwd", fields: fields{ + options: cache.Options{Address: "redis://username:password@redis:6379"}}, + want: wantValues{ + username: "username", + password: "password", + address: "redis:6379", + }}, + + {name: "Sentinel Failover client", fields: fields{ + options: cache.Options{ + Context: context.WithValue( + context.TODO(), redisOptionsContextKey{}, + rclient.UniversalOptions{MasterName: "master-name"}), + }}, + want: wantValues{ + username: "", + password: "", + address: "FailoverClient", // <- Placeholder set by NewFailoverClient + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + univClient := newUniversalClient(tt.fields.options) + client, ok := univClient.(*rclient.Client) + if !ok { + t.Errorf("newUniversalClient() expect a *redis.Client") + return + } + if client.Options().Addr != tt.want.address { + t.Errorf("newUniversalClient() Address = %v, want address %v", client.Options().Addr, tt.want.address) + } + if client.Options().Password != tt.want.password { + t.Errorf("newUniversalClient() password = %v, want password %v", client.Options().Password, tt.want.password) + } + if client.Options().Username != tt.want.username { + t.Errorf("newUniversalClient() username = %v, want username %v", client.Options().Username, tt.want.username) + } + }) + } +} + +func Test_newUniversalClientCluster(t *testing.T) { + type fields struct { + options cache.Options + } + type wantValues struct { + username string + password string + addrs []string + } + + tests := []struct { + name string + fields fields + want wantValues + }{ + {name: "Addrs in redis options", fields: fields{ + options: cache.Options{ + Address: "127.0.0.1:6379", // <- ignored + Context: context.WithValue( + context.TODO(), redisOptionsContextKey{}, + rclient.UniversalOptions{Addrs: []string{"127.0.0.1:6381", "127.0.0.1:6382"}}), + }}, + want: wantValues{ + username: "", + password: "", + addrs: []string{"127.0.0.1:6381", "127.0.0.1:6382"}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + univClient := newUniversalClient(tt.fields.options) + client, ok := univClient.(*rclient.ClusterClient) + if !ok { + t.Errorf("newUniversalClient() expect a *redis.ClusterClient") + return + } + if !reflect.DeepEqual(client.Options().Addrs, tt.want.addrs) { + t.Errorf("newUniversalClient() Addrs = %v, want addrs %v", client.Options().Addrs, tt.want.addrs) + } + if client.Options().Password != tt.want.password { + t.Errorf("newUniversalClient() password = %v, want password %v", client.Options().Password, tt.want.password) + } + if client.Options().Username != tt.want.username { + t.Errorf("newUniversalClient() username = %v, want username %v", client.Options().Username, tt.want.username) + } + }) + } +} diff --git a/cache/redis/redis.go b/cache/redis/redis.go new file mode 100644 index 00000000..350f9029 --- /dev/null +++ b/cache/redis/redis.go @@ -0,0 +1,57 @@ +package redis + +import ( + "context" + "time" + + rclient "github.com/go-redis/redis/v8" + "go-micro.dev/v5/cache" +) + +// NewRedisCache returns a new redis cache. +func NewRedisCache(opts ...cache.Option) cache.Cache { + options := cache.NewOptions(opts...) + return &redisCache{ + opts: options, + client: newUniversalClient(options), + } +} + +type redisCache struct { + opts cache.Options + client rclient.UniversalClient +} + +func (c *redisCache) Get(ctx context.Context, key string) (interface{}, time.Time, error) { + val, err := c.client.Get(ctx, key).Bytes() + if err != nil && err == rclient.Nil { + return nil, time.Time{}, cache.ErrKeyNotFound + } else if err != nil { + return nil, time.Time{}, err + } + + dur, err := c.client.TTL(ctx, key).Result() + if err != nil { + return nil, time.Time{}, err + } + if dur == -1 { + return val, time.Unix(1<<63-1, 0), nil + } + if dur == -2 { + return val, time.Time{}, cache.ErrItemExpired + } + + return val, time.Now().Add(dur), nil +} + +func (c *redisCache) Put(ctx context.Context, key string, val interface{}, dur time.Duration) error { + return c.client.Set(ctx, key, val, dur).Err() +} + +func (c *redisCache) Delete(ctx context.Context, key string) error { + return c.client.Del(ctx, key).Err() +} + +func (m *redisCache) String() string { + return "redis" +} diff --git a/cache/redis/redis_test.go b/cache/redis/redis_test.go new file mode 100644 index 00000000..e6c0f78d --- /dev/null +++ b/cache/redis/redis_test.go @@ -0,0 +1,88 @@ +package redis + +import ( + "context" + "os" + "testing" + "time" + + "go-micro.dev/v5/cache" +) + +var ( + ctx = context.TODO() + key string = "redistestkey" + val interface{} = "hello go-micro" + addr = cache.WithAddress("redis://127.0.0.1:6379") +) + +// TestMemCache tests the in-memory cache implementation. +func TestCache(t *testing.T) { + if len(os.Getenv("LOCAL")) == 0 { + t.Skip() + } + + t.Run("CacheGetMiss", func(t *testing.T) { + if _, _, err := NewRedisCache(addr).Get(ctx, key); err == nil { + t.Error("expected to get no value from cache") + } + }) + + t.Run("CacheGetHit", func(t *testing.T) { + c := NewRedisCache(addr) + + if err := c.Put(ctx, key, val, 0); err != nil { + t.Error(err) + } + + if a, _, err := c.Get(ctx, key); err != nil { + t.Errorf("Expected a value, got err: %s", err) + } else if string(a.([]byte)) != val { + t.Errorf("Expected '%v', got '%v'", val, a) + } + }) + + t.Run("CacheGetExpired", func(t *testing.T) { + c := NewRedisCache(addr) + d := 20 * time.Millisecond + + if err := c.Put(ctx, key, val, d); err != nil { + t.Error(err) + } + + <-time.After(25 * time.Millisecond) + if _, _, err := c.Get(ctx, key); err == nil { + t.Error("expected to get no value from cache") + } + }) + + t.Run("CacheGetValid", func(t *testing.T) { + c := NewRedisCache(addr) + e := 25 * time.Millisecond + + if err := c.Put(ctx, key, val, e); err != nil { + t.Error(err) + } + + <-time.After(20 * time.Millisecond) + if _, _, err := c.Get(ctx, key); err != nil { + t.Errorf("expected a value, got err: %s", err) + } + }) + + t.Run("CacheDeleteHit", func(t *testing.T) { + c := NewRedisCache(addr) + + if err := c.Put(ctx, key, val, 0); err != nil { + t.Error(err) + } + + if err := c.Delete(ctx, key); err != nil { + t.Errorf("Expected to delete an item, got err: %s", err) + } + + if _, _, err := c.Get(ctx, key); err == nil { + t.Errorf("Expected error") + } + }) +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 07e34905..3d764175 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -9,8 +9,12 @@ import ( "github.com/urfave/cli/v2" "go-micro.dev/v5/auth" + hbroker "go-micro.dev/v5/broker/http" + nbroker "go-micro.dev/v5/broker/nats" + "go-micro.dev/v5/broker" "go-micro.dev/v5/cache" + "go-micro.dev/v5/cache/redis" "go-micro.dev/v5/client" "go-micro.dev/v5/config" "go-micro.dev/v5/debug/profile" @@ -19,9 +23,14 @@ import ( "go-micro.dev/v5/debug/trace" "go-micro.dev/v5/logger" "go-micro.dev/v5/registry" + "go-micro.dev/v5/registry/consul" + "go-micro.dev/v5/registry/etcd" + "go-micro.dev/v5/registry/mdns" + "go-micro.dev/v5/registry/nats" "go-micro.dev/v5/selector" "go-micro.dev/v5/server" "go-micro.dev/v5/store" + "go-micro.dev/v5/store/mysql" "go-micro.dev/v5/transport" ) @@ -228,11 +237,21 @@ var ( }, } - DefaultBrokers = map[string]func(...broker.Option) broker.Broker{} + DefaultBrokers = map[string]func(...broker.Option) broker.Broker{ + "memory": broker.NewMemoryBroker, + "http": hbroker.NewHttpBroker, + "nats": nbroker.NewNatsBroker, + } DefaultClients = map[string]func(...client.Option) client.Client{} - DefaultRegistries = map[string]func(...registry.Option) registry.Registry{} + DefaultRegistries = map[string]func(...registry.Option) registry.Registry{ + "consul": consul.NewConsulRegistry, + "memory": registry.NewMemoryRegistry, + "nats": nats.NewNatsRegistry, + "mdns": mdns.NewMDNSRegistry, + "etcd": etcd.NewEtcdRegistry, + } DefaultSelectors = map[string]func(...selector.Option) selector.Selector{} @@ -240,7 +259,10 @@ var ( DefaultTransports = map[string]func(...transport.Option) transport.Transport{} - DefaultStores = map[string]func(...store.Option) store.Store{} + DefaultStores = map[string]func(...store.Option) store.Store{ + "memory": store.NewMemoryStore, + "mysql": mysql.NewMysqlStore, + } DefaultTracers = map[string]func(...trace.Option) trace.Tracer{} @@ -253,7 +275,9 @@ var ( DefaultConfigs = map[string]func(...config.Option) (config.Config, error){} - DefaultCaches = map[string]func(...cache.Option) cache.Cache{} + DefaultCaches = map[string]func(...cache.Option) cache.Cache{ + "redis": redis.NewRedisCache, + } ) func init() { @@ -349,7 +373,7 @@ func (c *cmd) Before(ctx *cli.Context) error { if name := ctx.String("store"); len(name) > 0 { s, ok := c.opts.Stores[name] if !ok { - return fmt.Errorf("Unsupported store: %s", name) + return fmt.Errorf("unsupported store: %s", name) } *c.opts.Store = s(store.WithClient(*c.opts.Client)) @@ -359,7 +383,7 @@ func (c *cmd) Before(ctx *cli.Context) error { if name := ctx.String("tracer"); len(name) > 0 { r, ok := c.opts.Tracers[name] if !ok { - return fmt.Errorf("Unsupported tracer: %s", name) + return fmt.Errorf("unsupported tracer: %s", name) } *c.opts.Tracer = r() @@ -385,7 +409,7 @@ func (c *cmd) Before(ctx *cli.Context) error { if name := ctx.String("auth"); len(name) > 0 { r, ok := c.opts.Auths[name] if !ok { - return fmt.Errorf("Unsupported auth: %s", name) + return fmt.Errorf("unsupported auth: %s", name) } *c.opts.Auth = r(authOpts...) @@ -417,7 +441,7 @@ func (c *cmd) Before(ctx *cli.Context) error { if name := ctx.String("profile"); len(name) > 0 { p, ok := c.opts.Profiles[name] if !ok { - return fmt.Errorf("Unsupported profile: %s", name) + return fmt.Errorf("unsupported profile: %s", name) } *c.opts.Profile = p() diff --git a/go.mod b/go.mod index 5d8810b6..028473d1 100644 --- a/go.mod +++ b/go.mod @@ -6,39 +6,75 @@ toolchain go1.24.1 require ( github.com/bitly/go-simplejson v0.5.0 - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/fsnotify/fsnotify v1.6.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/go-sql-driver/mysql v1.9.2 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 + github.com/hashicorp/consul/api v1.32.1 github.com/imdario/mergo v0.3.12 github.com/kr/pretty v0.3.0 - github.com/miekg/dns v1.1.43 + github.com/miekg/dns v1.1.50 + github.com/mitchellh/hashstructure v1.1.0 + github.com/nats-io/nats.go v1.42.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.25.7 go.etcd.io/bbolt v1.4.0 - golang.org/x/crypto v0.36.0 + go.etcd.io/etcd/api/v3 v3.5.21 + go.etcd.io/etcd/client/v3 v3.5.21 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.37.0 golang.org/x/net v0.38.0 - golang.org/x/sync v0.12.0 + golang.org/x/sync v0.13.0 google.golang.org/grpc v1.72.1 google.golang.org/grpc/examples v0.0.0-20250514161145-5c0d55244474 google.golang.org/protobuf v1.36.6 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/armon/go-metrics v0.4.1 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/serf v0.10.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/kr/text v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.6.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.31.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0afc84bb..82c1c1a6 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,136 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE= +github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -30,30 +139,114 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= -github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= +github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0= +github.com/mitchellh/hashstructure v1.1.0/go.mod h1:xUDAozZz0Wmdiufv0uyhnHkUTN6/6d8ulp4AwfLKrmA= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/nats.go v1.42.0 h1:ynIMupIOvf/ZWH/b2qda6WGKGNSjwOUutTpWRvAmhaM= +github.com/nats-io/nats.go v1.42.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= +go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY= +go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc= +go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= +go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= +go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= @@ -66,24 +259,96 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= @@ -92,11 +357,19 @@ google.golang.org/grpc/examples v0.0.0-20250514161145-5c0d55244474 h1:7B8e8jJRSI google.golang.org/grpc/examples v0.0.0-20250514161145-5c0d55244474/go.mod h1:WPWnet+nYurNGpV0rVYHI1YuOJwVHeM3t8f76m410XM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/registry/consul/consul.go b/registry/consul/consul.go new file mode 100644 index 00000000..46902313 --- /dev/null +++ b/registry/consul/consul.go @@ -0,0 +1,460 @@ +package consul + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "runtime" + "strconv" + "strings" + "sync" + "time" + + consul "github.com/hashicorp/consul/api" + hash "github.com/mitchellh/hashstructure" + "go-micro.dev/v5/registry" + mnet "go-micro.dev/v5/util/net" +) + +type consulRegistry struct { + Address []string + opts registry.Options + + client *consul.Client + config *consul.Config + + // connect enabled + connect bool + + queryOptions *consul.QueryOptions + + sync.Mutex + register map[string]uint64 + // lastChecked tracks when a node was last checked as existing in Consul + lastChecked map[string]time.Time +} + +func getDeregisterTTL(t time.Duration) time.Duration { + // splay slightly for the watcher? + splay := time.Second * 5 + deregTTL := t + splay + + // consul has a minimum timeout on deregistration of 1 minute. + if t < time.Minute { + deregTTL = time.Minute + splay + } + + return deregTTL +} + +func newTransport(config *tls.Config) *http.Transport { + if config == nil { + config = &tls.Config{ + InsecureSkipVerify: true, + } + } + + t := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: config, + } + runtime.SetFinalizer(&t, func(tr **http.Transport) { + (*tr).CloseIdleConnections() + }) + return t +} + +func configure(c *consulRegistry, opts ...registry.Option) { + // set opts + for _, o := range opts { + o(&c.opts) + } + + // use default non pooled config + config := consul.DefaultNonPooledConfig() + + if c.opts.Context != nil { + // Use the consul config passed in the options, if available + if co, ok := c.opts.Context.Value("consul_config").(*consul.Config); ok { + config = co + } + if cn, ok := c.opts.Context.Value("consul_connect").(bool); ok { + c.connect = cn + } + + // Use the consul query options passed in the options, if available + if qo, ok := c.opts.Context.Value("consul_query_options").(*consul.QueryOptions); ok && qo != nil { + c.queryOptions = qo + } + if as, ok := c.opts.Context.Value("consul_allow_stale").(bool); ok { + c.queryOptions.AllowStale = as + } + } + + // check if there are any addrs + var addrs []string + + // iterate the options addresses + for _, address := range c.opts.Addrs { + // check we have a port + addr, port, err := net.SplitHostPort(address) + if ae, ok := err.(*net.AddrError); ok && ae.Err == "missing port in address" { + port = "8500" + addr = address + addrs = append(addrs, net.JoinHostPort(addr, port)) + } else if err == nil { + addrs = append(addrs, net.JoinHostPort(addr, port)) + } + } + + // set the addrs + if len(addrs) > 0 { + c.Address = addrs + config.Address = c.Address[0] + } + + if config.HttpClient == nil { + config.HttpClient = new(http.Client) + } + + // requires secure connection? + if c.opts.Secure || c.opts.TLSConfig != nil { + config.Scheme = "https" + // We're going to support InsecureSkipVerify + config.HttpClient.Transport = newTransport(c.opts.TLSConfig) + } + + // set timeout + if c.opts.Timeout > 0 { + config.HttpClient.Timeout = c.opts.Timeout + } + + // set the config + c.config = config + + // remove client + c.client = nil + + // setup the client + c.Client() +} + +func (c *consulRegistry) Init(opts ...registry.Option) error { + configure(c, opts...) + return nil +} + +func (c *consulRegistry) Deregister(s *registry.Service, opts ...registry.DeregisterOption) error { + if len(s.Nodes) == 0 { + return errors.New("require at least one node") + } + + // delete our hash and time check of the service + c.Lock() + delete(c.register, s.Name) + delete(c.lastChecked, s.Name) + c.Unlock() + + node := s.Nodes[0] + return c.Client().Agent().ServiceDeregister(node.Id) +} + +func (c *consulRegistry) Register(s *registry.Service, opts ...registry.RegisterOption) error { + if len(s.Nodes) == 0 { + return errors.New("require at least one node") + } + + var regTCPCheck bool + var regInterval time.Duration + var regHTTPCheck bool + var httpCheckConfig consul.AgentServiceCheck + + var options registry.RegisterOptions + for _, o := range opts { + o(&options) + } + + if c.opts.Context != nil { + if tcpCheckInterval, ok := c.opts.Context.Value("consul_tcp_check").(time.Duration); ok { + regTCPCheck = true + regInterval = tcpCheckInterval + } + var ok bool + if httpCheckConfig, ok = c.opts.Context.Value("consul_http_check_config").(consul.AgentServiceCheck); ok { + regHTTPCheck = true + } + } + + // create hash of service; uint64 + h, err := hash.Hash(s, nil) + if err != nil { + return err + } + + // use first node + node := s.Nodes[0] + + // get existing hash and last checked time + c.Lock() + v, ok := c.register[s.Name] + lastChecked := c.lastChecked[s.Name] + c.Unlock() + + // if it's already registered and matches then just pass the check + if ok && v == h { + if options.TTL == time.Duration(0) { + // ensure that our service hasn't been deregistered by Consul + if time.Since(lastChecked) <= getDeregisterTTL(regInterval) { + return nil + } + services, _, err := c.Client().Health().Checks(s.Name, c.queryOptions) + if err == nil { + for _, v := range services { + if v.ServiceID == node.Id { + return nil + } + } + } + } else { + // if the err is nil we're all good, bail out + // if not, we don't know what the state is, so full re-register + if err := c.Client().Agent().PassTTL("service:"+node.Id, ""); err == nil { + return nil + } + } + } + + // encode the tags + tags := encodeMetadata(node.Metadata) + tags = append(tags, encodeEndpoints(s.Endpoints)...) + tags = append(tags, encodeVersion(s.Version)...) + + var check *consul.AgentServiceCheck + + if regTCPCheck { + deregTTL := getDeregisterTTL(regInterval) + + check = &consul.AgentServiceCheck{ + TCP: node.Address, + Interval: fmt.Sprintf("%v", regInterval), + DeregisterCriticalServiceAfter: fmt.Sprintf("%v", deregTTL), + } + + } else if regHTTPCheck { + interval, _ := time.ParseDuration(httpCheckConfig.Interval) + deregTTL := getDeregisterTTL(interval) + + host, _, _ := net.SplitHostPort(node.Address) + healthCheckURI := strings.Replace(httpCheckConfig.HTTP, "{host}", host, 1) + + check = &consul.AgentServiceCheck{ + HTTP: healthCheckURI, + Interval: httpCheckConfig.Interval, + Timeout: httpCheckConfig.Timeout, + DeregisterCriticalServiceAfter: fmt.Sprintf("%v", deregTTL), + } + + // if the TTL is greater than 0 create an associated check + } else if options.TTL > time.Duration(0) { + deregTTL := getDeregisterTTL(options.TTL) + + check = &consul.AgentServiceCheck{ + TTL: fmt.Sprintf("%v", options.TTL), + DeregisterCriticalServiceAfter: fmt.Sprintf("%v", deregTTL), + } + } + + host, pt, _ := net.SplitHostPort(node.Address) + if host == "" { + host = node.Address + } + port, _ := strconv.Atoi(pt) + + // register the service + asr := &consul.AgentServiceRegistration{ + ID: node.Id, + Name: s.Name, + Tags: tags, + Port: port, + Address: host, + Meta: node.Metadata, + Check: check, + } + + // Specify consul connect + if c.connect { + asr.Connect = &consul.AgentServiceConnect{ + Native: true, + } + } + + if err := c.Client().Agent().ServiceRegister(asr); err != nil { + return err + } + + // save our hash and time check of the service + c.Lock() + c.register[s.Name] = h + c.lastChecked[s.Name] = time.Now() + c.Unlock() + + // if the TTL is 0 we don't mess with the checks + if options.TTL == time.Duration(0) { + return nil + } + + // pass the healthcheck + return c.Client().Agent().PassTTL("service:"+node.Id, "") +} + +func (c *consulRegistry) GetService(name string, opts ...registry.GetOption) ([]*registry.Service, error) { + var rsp []*consul.ServiceEntry + var err error + + // if we're connect enabled only get connect services + if c.connect { + rsp, _, err = c.Client().Health().Connect(name, "", false, c.queryOptions) + } else { + rsp, _, err = c.Client().Health().Service(name, "", false, c.queryOptions) + } + if err != nil { + return nil, err + } + + serviceMap := map[string]*registry.Service{} + + for _, s := range rsp { + if s.Service.Service != name { + continue + } + + // version is now a tag + version, _ := decodeVersion(s.Service.Tags) + // service ID is now the node id + id := s.Service.ID + // key is always the version + key := version + + // address is service address + address := s.Service.Address + + // use node address + if len(address) == 0 { + address = s.Node.Address + } + + svc, ok := serviceMap[key] + if !ok { + svc = ®istry.Service{ + Endpoints: decodeEndpoints(s.Service.Tags), + Name: s.Service.Service, + Version: version, + } + serviceMap[key] = svc + } + + var del bool + + for _, check := range s.Checks { + // delete the node if the status is critical + if check.Status == "critical" { + del = true + break + } + } + + // if delete then skip the node + if del { + continue + } + + svc.Nodes = append(svc.Nodes, ®istry.Node{ + Id: id, + Address: mnet.HostPort(address, s.Service.Port), + Metadata: decodeMetadata(s.Service.Tags), + }) + } + + var services []*registry.Service + for _, service := range serviceMap { + services = append(services, service) + } + return services, nil +} + +func (c *consulRegistry) ListServices(opts ...registry.ListOption) ([]*registry.Service, error) { + rsp, _, err := c.Client().Catalog().Services(c.queryOptions) + if err != nil { + return nil, err + } + + var services []*registry.Service + + for service := range rsp { + services = append(services, ®istry.Service{Name: service}) + } + + return services, nil +} + +func (c *consulRegistry) Watch(opts ...registry.WatchOption) (registry.Watcher, error) { + return newConsulWatcher(c, opts...) +} + +func (c *consulRegistry) String() string { + return "consul" +} + +func (c *consulRegistry) Options() registry.Options { + return c.opts +} + +func (c *consulRegistry) Client() *consul.Client { + if c.client != nil { + return c.client + } + + for _, addr := range c.Address { + // set the address + c.config.Address = addr + + // create a new client + tmpClient, _ := consul.NewClient(c.config) + + // test the client + _, err := tmpClient.Agent().Host() + if err != nil { + continue + } + + // set the client + c.client = tmpClient + return c.client + } + + // set the default + c.client, _ = consul.NewClient(c.config) + + // return the client + return c.client +} + +func NewConsulRegistry(opts ...registry.Option) registry.Registry { + cr := &consulRegistry{ + opts: registry.Options{}, + register: make(map[string]uint64), + lastChecked: make(map[string]time.Time), + queryOptions: &consul.QueryOptions{ + AllowStale: true, + }, + } + configure(cr, opts...) + return cr +} diff --git a/registry/consul/encoding.go b/registry/consul/encoding.go new file mode 100644 index 00000000..d7942a13 --- /dev/null +++ b/registry/consul/encoding.go @@ -0,0 +1,171 @@ +package consul + +import ( + "bytes" + "compress/zlib" + "encoding/hex" + "encoding/json" + "io" + + "go-micro.dev/v5/registry" +) + +func encode(buf []byte) string { + var b bytes.Buffer + defer b.Reset() + + w := zlib.NewWriter(&b) + if _, err := w.Write(buf); err != nil { + return "" + } + w.Close() + + return hex.EncodeToString(b.Bytes()) +} + +func decode(d string) []byte { + hr, err := hex.DecodeString(d) + if err != nil { + return nil + } + + br := bytes.NewReader(hr) + zr, err := zlib.NewReader(br) + if err != nil { + return nil + } + + rbuf, err := io.ReadAll(zr) + if err != nil { + return nil + } + zr.Close() + + return rbuf +} + +func encodeEndpoints(en []*registry.Endpoint) []string { + var tags []string + for _, e := range en { + if b, err := json.Marshal(e); err == nil { + tags = append(tags, "e-"+encode(b)) + } + } + return tags +} + +func decodeEndpoints(tags []string) []*registry.Endpoint { + var en []*registry.Endpoint + + // use the first format you find + var ver byte + + for _, tag := range tags { + if len(tag) == 0 || tag[0] != 'e' { + continue + } + + // check version + if ver > 0 && tag[1] != ver { + continue + } + + var e *registry.Endpoint + var buf []byte + + // Old encoding was plain + if tag[1] == '=' { + buf = []byte(tag[2:]) + } + + // New encoding is hex + if tag[1] == '-' { + buf = decode(tag[2:]) + } + + if err := json.Unmarshal(buf, &e); err == nil { + en = append(en, e) + } + + // set version + ver = tag[1] + } + return en +} + +func encodeMetadata(md map[string]string) []string { + var tags []string + for k, v := range md { + if b, err := json.Marshal(map[string]string{ + k: v, + }); err == nil { + // new encoding + tags = append(tags, "t-"+encode(b)) + } + } + return tags +} + +func decodeMetadata(tags []string) map[string]string { + md := make(map[string]string) + + var ver byte + + for _, tag := range tags { + if len(tag) == 0 || tag[0] != 't' { + continue + } + + // check version + if ver > 0 && tag[1] != ver { + continue + } + + var kv map[string]string + var buf []byte + + // Old encoding was plain + if tag[1] == '=' { + buf = []byte(tag[2:]) + } + + // New encoding is hex + if tag[1] == '-' { + buf = decode(tag[2:]) + } + + // Now unmarshal + if err := json.Unmarshal(buf, &kv); err == nil { + for k, v := range kv { + md[k] = v + } + } + + // set version + ver = tag[1] + } + return md +} + +func encodeVersion(v string) []string { + return []string{"v-" + encode([]byte(v))} +} + +func decodeVersion(tags []string) (string, bool) { + for _, tag := range tags { + if len(tag) < 2 || tag[0] != 'v' { + continue + } + + // Old encoding was plain + if tag[1] == '=' { + return tag[2:], true + } + + // New encoding is hex + if tag[1] == '-' { + return string(decode(tag[2:])), true + } + } + return "", false +} diff --git a/registry/consul/encoding_test.go b/registry/consul/encoding_test.go new file mode 100644 index 00000000..7dfe2e96 --- /dev/null +++ b/registry/consul/encoding_test.go @@ -0,0 +1,147 @@ +package consul + +import ( + "encoding/json" + "testing" + + "go-micro.dev/v5/registry" +) + +func TestEncodingEndpoints(t *testing.T) { + eps := []*registry.Endpoint{ + { + Name: "endpoint1", + Request: ®istry.Value{ + Name: "request", + Type: "request", + }, + Response: ®istry.Value{ + Name: "response", + Type: "response", + }, + Metadata: map[string]string{ + "foo1": "bar1", + }, + }, + { + Name: "endpoint2", + Request: ®istry.Value{ + Name: "request", + Type: "request", + }, + Response: ®istry.Value{ + Name: "response", + Type: "response", + }, + Metadata: map[string]string{ + "foo2": "bar2", + }, + }, + { + Name: "endpoint3", + Request: ®istry.Value{ + Name: "request", + Type: "request", + }, + Response: ®istry.Value{ + Name: "response", + Type: "response", + }, + Metadata: map[string]string{ + "foo3": "bar3", + }, + }, + } + + testEp := func(ep *registry.Endpoint, enc string) { + // encode endpoint + e := encodeEndpoints([]*registry.Endpoint{ep}) + + // check there are two tags; old and new + if len(e) != 1 { + t.Fatalf("Expected 1 encoded tags, got %v", e) + } + + // check old encoding + var seen bool + + for _, en := range e { + if en == enc { + seen = true + break + } + } + + if !seen { + t.Fatalf("Expected %s but not found", enc) + } + + // decode + d := decodeEndpoints([]string{enc}) + if len(d) == 0 { + t.Fatalf("Expected %v got %v", ep, d) + } + + // check name + if d[0].Name != ep.Name { + t.Fatalf("Expected ep %s got %s", ep.Name, d[0].Name) + } + + // check all the metadata exists + for k, v := range ep.Metadata { + if gv := d[0].Metadata[k]; gv != v { + t.Fatalf("Expected key %s val %s got val %s", k, v, gv) + } + } + } + + for _, ep := range eps { + // JSON encoded + jencoded, err := json.Marshal(ep) + if err != nil { + t.Fatal(err) + } + + // HEX encoded + hencoded := encode(jencoded) + // endpoint tag + hepTag := "e-" + hencoded + testEp(ep, hepTag) + } +} + +func TestEncodingVersion(t *testing.T) { + testData := []struct { + decoded string + encoded string + }{ + {"1.0.0", "v-789c32d433d03300040000ffff02ce00ee"}, + {"latest", "v-789cca492c492d2e01040000ffff08cc028e"}, + } + + for _, data := range testData { + e := encodeVersion(data.decoded) + + if e[0] != data.encoded { + t.Fatalf("Expected %s got %s", data.encoded, e) + } + + d, ok := decodeVersion(e) + if !ok { + t.Fatalf("Unexpected %t for %s", ok, data.encoded) + } + + if d != data.decoded { + t.Fatalf("Expected %s got %s", data.decoded, d) + } + + d, ok = decodeVersion([]string{data.encoded}) + if !ok { + t.Fatalf("Unexpected %t for %s", ok, data.encoded) + } + + if d != data.decoded { + t.Fatalf("Expected %s got %s", data.decoded, d) + } + } +} diff --git a/registry/consul/options.go b/registry/consul/options.go new file mode 100644 index 00000000..9fac4f8b --- /dev/null +++ b/registry/consul/options.go @@ -0,0 +1,111 @@ +package consul + +import ( + "context" + "fmt" + "time" + + consul "github.com/hashicorp/consul/api" + "go-micro.dev/v5/registry" +) + +// Define a custom type for context keys to avoid collisions. +type contextKey string + +const consulConnectKey contextKey = "consul_connect" +const consulConfigKey contextKey = "consul_config" +const consulAllowStaleKey contextKey = "consul_allow_stale" +const consulQueryOptionsKey contextKey = "consul_query_options" +const consulTCPCheckKey contextKey = "consul_tcp_check" +const consulHTTPCheckConfigKey contextKey = "consul_http_check_config" + +// Connect specifies services should be registered as Consul Connect services. +func Connect() registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, consulConnectKey, true) + } +} + +func Config(c *consul.Config) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, consulConfigKey, c) + } +} + +// AllowStale sets whether any Consul server (non-leader) can service +// a read. This allows for lower latency and higher throughput +// at the cost of potentially stale data. +// Works similar to Consul DNS Config option [1]. +// Defaults to true. +// +// [1] https://www.consul.io/docs/agent/options.html#allow_stale +func AllowStale(v bool) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, consulAllowStaleKey, v) + } +} + +// QueryOptions specifies the QueryOptions to be used when calling +// Consul. See `Consul API` for more information [1]. +// +// [1] https://godoc.org/github.com/hashicorp/consul/api#QueryOptions +func QueryOptions(q *consul.QueryOptions) registry.Option { + return func(o *registry.Options) { + if q == nil { + return + } + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, consulQueryOptionsKey, q) + } +} + +// TCPCheck will tell the service provider to check the service address +// and port every `t` interval. It will enabled only if `t` is greater than 0. +// See `TCP + Interval` for more information [1]. +// +// [1] https://www.consul.io/docs/agent/checks.html +func TCPCheck(t time.Duration) registry.Option { + return func(o *registry.Options) { + if t <= time.Duration(0) { + return + } + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, consulTCPCheckKey, t) + } +} + +// HTTPCheck will tell the service provider to invoke the health check endpoint +// with an interval and timeout. It will be enabled only if interval and +// timeout are greater than 0. +// See `HTTP + Interval` for more information [1]. +// +// [1] https://www.consul.io/docs/agent/checks.html +func HTTPCheck(protocol, port, httpEndpoint string, interval, timeout time.Duration) registry.Option { + return func(o *registry.Options) { + if interval <= time.Duration(0) || timeout <= time.Duration(0) { + return + } + if o.Context == nil { + o.Context = context.Background() + } + check := consul.AgentServiceCheck{ + HTTP: fmt.Sprintf("%s://{host}:%s%s", protocol, port, httpEndpoint), + Interval: fmt.Sprintf("%v", interval), + Timeout: fmt.Sprintf("%v", timeout), + } + o.Context = context.WithValue(o.Context, consulHTTPCheckConfigKey, check) + } +} diff --git a/registry/consul/registry_test.go b/registry/consul/registry_test.go new file mode 100644 index 00000000..5efbd303 --- /dev/null +++ b/registry/consul/registry_test.go @@ -0,0 +1,208 @@ +package consul + +import ( + "bytes" + "encoding/json" + "errors" + "net" + "net/http" + "testing" + "time" + + consul "github.com/hashicorp/consul/api" + "go-micro.dev/v5/registry" +) + +type mockRegistry struct { + body []byte + status int + err error + url string +} + +func encodeData(obj interface{}) ([]byte, error) { + buf := bytes.NewBuffer(nil) + enc := json.NewEncoder(buf) + if err := enc.Encode(obj); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func newMockServer(rg *mockRegistry, l net.Listener) error { + mux := http.NewServeMux() + mux.HandleFunc(rg.url, func(w http.ResponseWriter, r *http.Request) { + if rg.err != nil { + http.Error(w, rg.err.Error(), 500) + return + } + w.WriteHeader(rg.status) + w.Write(rg.body) + }) + return http.Serve(l, mux) +} + +func newConsulTestRegistry(r *mockRegistry) (*consulRegistry, func()) { + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + // blurgh?!! + panic(err.Error()) + } + cfg := consul.DefaultConfig() + cfg.Address = l.Addr().String() + + go newMockServer(r, l) + + var cr = &consulRegistry{ + config: cfg, + Address: []string{cfg.Address}, + opts: registry.Options{}, + register: make(map[string]uint64), + lastChecked: make(map[string]time.Time), + queryOptions: &consul.QueryOptions{ + AllowStale: true, + }, + } + cr.Client() + + return cr, func() { + l.Close() + } +} + +func newServiceList(svc []*consul.ServiceEntry) []byte { + bts, _ := encodeData(svc) + return bts +} + +func TestConsul_GetService_WithError(t *testing.T) { + cr, cl := newConsulTestRegistry(&mockRegistry{ + err: errors.New("client-error"), + url: "/v1/health/service/service-name", + }) + defer cl() + + if _, err := cr.GetService("test-service"); err == nil { + t.Fatalf("Expected error not to be `nil`") + } +} + +func TestConsul_GetService_WithHealthyServiceNodes(t *testing.T) { + // warning is still seen as healthy, critical is not + svcs := []*consul.ServiceEntry{ + newServiceEntry( + "node-name-1", "node-address-1", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-1", "service-name", "passing"), + newHealthCheck("node-name-1", "service-name", "warning"), + }, + ), + newServiceEntry( + "node-name-2", "node-address-2", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-2", "service-name", "passing"), + newHealthCheck("node-name-2", "service-name", "warning"), + }, + ), + } + + cr, cl := newConsulTestRegistry(&mockRegistry{ + status: 200, + body: newServiceList(svcs), + url: "/v1/health/service/service-name", + }) + defer cl() + + svc, err := cr.GetService("service-name") + if err != nil { + t.Fatal("Unexpected error", err) + } + + if exp, act := 1, len(svc); exp != act { + t.Fatalf("Expected len of svc to be `%d`, got `%d`.", exp, act) + } + + if exp, act := 2, len(svc[0].Nodes); exp != act { + t.Fatalf("Expected len of nodes to be `%d`, got `%d`.", exp, act) + } +} + +func TestConsul_GetService_WithUnhealthyServiceNode(t *testing.T) { + // warning is still seen as healthy, critical is not + svcs := []*consul.ServiceEntry{ + newServiceEntry( + "node-name-1", "node-address-1", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-1", "service-name", "passing"), + newHealthCheck("node-name-1", "service-name", "warning"), + }, + ), + newServiceEntry( + "node-name-2", "node-address-2", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-2", "service-name", "passing"), + newHealthCheck("node-name-2", "service-name", "critical"), + }, + ), + } + + cr, cl := newConsulTestRegistry(&mockRegistry{ + status: 200, + body: newServiceList(svcs), + url: "/v1/health/service/service-name", + }) + defer cl() + + svc, err := cr.GetService("service-name") + if err != nil { + t.Fatal("Unexpected error", err) + } + + if exp, act := 1, len(svc); exp != act { + t.Fatalf("Expected len of svc to be `%d`, got `%d`.", exp, act) + } + + if exp, act := 1, len(svc[0].Nodes); exp != act { + t.Fatalf("Expected len of nodes to be `%d`, got `%d`.", exp, act) + } +} + +func TestConsul_GetService_WithUnhealthyServiceNodes(t *testing.T) { + // warning is still seen as healthy, critical is not + svcs := []*consul.ServiceEntry{ + newServiceEntry( + "node-name-1", "node-address-1", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-1", "service-name", "passing"), + newHealthCheck("node-name-1", "service-name", "critical"), + }, + ), + newServiceEntry( + "node-name-2", "node-address-2", "service-name", "v1.0.0", + []*consul.HealthCheck{ + newHealthCheck("node-name-2", "service-name", "passing"), + newHealthCheck("node-name-2", "service-name", "critical"), + }, + ), + } + + cr, cl := newConsulTestRegistry(&mockRegistry{ + status: 200, + body: newServiceList(svcs), + url: "/v1/health/service/service-name", + }) + defer cl() + + svc, err := cr.GetService("service-name") + if err != nil { + t.Fatal("Unexpected error", err) + } + + if exp, act := 1, len(svc); exp != act { + t.Fatalf("Expected len of svc to be `%d`, got `%d`.", exp, act) + } + + if exp, act := 0, len(svc[0].Nodes); exp != act { + t.Fatalf("Expected len of nodes to be `%d`, got `%d`.", exp, act) + } +} diff --git a/registry/consul/watcher.go b/registry/consul/watcher.go new file mode 100644 index 00000000..c37f4ed9 --- /dev/null +++ b/registry/consul/watcher.go @@ -0,0 +1,299 @@ +package consul + +import ( + "fmt" + "net" + "sync" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/api/watch" + "go-micro.dev/v5/registry" + regutil "go-micro.dev/v5/util/registry" +) + +type consulWatcher struct { + r *consulRegistry + wo registry.WatchOptions + wp *watch.Plan + watchers map[string]*watch.Plan + + next chan *registry.Result + exit chan bool + + sync.RWMutex + services map[string][]*registry.Service +} + +func newConsulWatcher(cr *consulRegistry, opts ...registry.WatchOption) (registry.Watcher, error) { + var wo registry.WatchOptions + for _, o := range opts { + o(&wo) + } + + cw := &consulWatcher{ + r: cr, + wo: wo, + exit: make(chan bool), + next: make(chan *registry.Result, 10), + watchers: make(map[string]*watch.Plan), + services: make(map[string][]*registry.Service), + } + + wp, err := watch.Parse(map[string]interface{}{ + "service": wo.Service, + "type": "service", + }) + if err != nil { + return nil, err + } + + wp.Handler = cw.serviceHandler + go wp.RunWithClientAndHclog(cr.Client(), wp.Logger) + cw.wp = wp + + return cw, nil +} + +func (cw *consulWatcher) serviceHandler(idx uint64, data interface{}) { + entries, ok := data.([]*api.ServiceEntry) + if !ok { + return + } + + serviceMap := map[string]*registry.Service{} + serviceName := "" + + for _, e := range entries { + serviceName = e.Service.Service + // version is now a tag + version, _ := decodeVersion(e.Service.Tags) + // service ID is now the node id + id := e.Service.ID + // key is always the version + key := version + // address is service address + address := e.Service.Address + + // use node address + if len(address) == 0 { + address = e.Node.Address + } + + svc, ok := serviceMap[key] + if !ok { + svc = ®istry.Service{ + Endpoints: decodeEndpoints(e.Service.Tags), + Name: e.Service.Service, + Version: version, + } + serviceMap[key] = svc + } + + var del bool + + for _, check := range e.Checks { + // delete the node if the status is critical + if check.Status == "critical" { + del = true + break + } + } + + // if delete then skip the node + if del { + continue + } + + svc.Nodes = append(svc.Nodes, ®istry.Node{ + Id: id, + Address: net.JoinHostPort(address, fmt.Sprint(e.Service.Port)), + Metadata: decodeMetadata(e.Service.Tags), + }) + } + + cw.RLock() + // make a copy + rservices := make(map[string][]*registry.Service) + for k, v := range cw.services { + rservices[k] = v + } + cw.RUnlock() + + var newServices []*registry.Service + + // serviceMap is the new set of services keyed by name+version + for _, newService := range serviceMap { + // append to the new set of cached services + newServices = append(newServices, newService) + + // check if the service exists in the existing cache + oldServices, ok := rservices[serviceName] + if !ok { + // does not exist? then we're creating brand new entries + cw.next <- ®istry.Result{Action: "create", Service: newService} + continue + } + + // service exists. ok let's figure out what to update and delete version wise + action := "create" + + for _, oldService := range oldServices { + // does this version exist? + // no? then default to create + if oldService.Version != newService.Version { + continue + } + + // yes? then it's an update + action = "update" + + var nodes []*registry.Node + // check the old nodes to see if they've been deleted + for _, oldNode := range oldService.Nodes { + var seen bool + for _, newNode := range newService.Nodes { + if newNode.Id == oldNode.Id { + seen = true + break + } + } + // does the old node exist in the new set of nodes + // no? then delete that shit + if !seen { + nodes = append(nodes, oldNode) + } + } + + // it's an update rather than creation + if len(nodes) > 0 { + delService := regutil.CopyService(oldService) + delService.Nodes = nodes + cw.next <- ®istry.Result{Action: "delete", Service: delService} + } + } + + cw.next <- ®istry.Result{Action: action, Service: newService} + } + + // Now check old versions that may not be in new services map + for _, old := range rservices[serviceName] { + // old version does not exist in new version map + // kill it with fire! + if _, ok := serviceMap[old.Version]; !ok { + cw.next <- ®istry.Result{Action: "delete", Service: old} + } + } + + // there are no services in the service, empty all services + if len(rservices) != 0 && serviceName == "" { + for _, services := range rservices { + for _, service := range services { + cw.next <- ®istry.Result{Action: "delete", Service: service} + } + } + } + + cw.Lock() + cw.services[serviceName] = newServices + cw.Unlock() +} + +func (cw *consulWatcher) handle(idx uint64, data interface{}) { + services, ok := data.(map[string][]string) + if !ok { + return + } + + // add new watchers + for service := range services { + // Filter on watch options + // wo.Service: Only watch services we care about + if len(cw.wo.Service) > 0 && service != cw.wo.Service { + continue + } + + if _, ok := cw.watchers[service]; ok { + continue + } + wp, err := watch.Parse(map[string]interface{}{ + "type": "service", + "service": service, + }) + if err == nil { + wp.Handler = cw.serviceHandler + go wp.RunWithClientAndHclog(cw.r.Client(), wp.Logger) + cw.watchers[service] = wp + cw.next <- ®istry.Result{Action: "create", Service: ®istry.Service{Name: service}} + } + } + + cw.RLock() + // make a copy + rservices := make(map[string][]*registry.Service) + for k, v := range cw.services { + rservices[k] = v + } + cw.RUnlock() + + // remove unknown services from registry + // save the things we want to delete + deleted := make(map[string][]*registry.Service) + + for service := range rservices { + if _, ok := services[service]; !ok { + cw.Lock() + // save this before deleting + deleted[service] = cw.services[service] + delete(cw.services, service) + cw.Unlock() + } + } + + // remove unknown services from watchers + for service, w := range cw.watchers { + if _, ok := services[service]; !ok { + w.Stop() + delete(cw.watchers, service) + for _, oldService := range deleted[service] { + // send a delete for the service nodes that we're removing + cw.next <- ®istry.Result{Action: "delete", Service: oldService} + } + // sent the empty list as the last resort to indicate to delete the entire service + cw.next <- ®istry.Result{Action: "delete", Service: ®istry.Service{Name: service}} + } + } +} + +func (cw *consulWatcher) Next() (*registry.Result, error) { + select { + case <-cw.exit: + return nil, registry.ErrWatcherStopped + case r, ok := <-cw.next: + if !ok { + return nil, registry.ErrWatcherStopped + } + return r, nil + } +} + +func (cw *consulWatcher) Stop() { + select { + case <-cw.exit: + return + default: + close(cw.exit) + if cw.wp == nil { + return + } + cw.wp.Stop() + + // drain results + for { + select { + case <-cw.next: + default: + return + } + } + } +} diff --git a/registry/consul/watcher_test.go b/registry/consul/watcher_test.go new file mode 100644 index 00000000..0dffb3a2 --- /dev/null +++ b/registry/consul/watcher_test.go @@ -0,0 +1,86 @@ +package consul + +import ( + "testing" + + "github.com/hashicorp/consul/api" + "go-micro.dev/v5/registry" +) + +func TestHealthyServiceHandler(t *testing.T) { + watcher := newWatcher() + serviceEntry := newServiceEntry( + "node-name", "node-address", "service-name", "v1.0.0", + []*api.HealthCheck{ + newHealthCheck("node-name", "service-name", "passing"), + }, + ) + + watcher.serviceHandler(1234, []*api.ServiceEntry{serviceEntry}) + + if len(watcher.services["service-name"][0].Nodes) != 1 { + t.Errorf("Expected length of the service nodes to be 1") + } +} + +func TestUnhealthyServiceHandler(t *testing.T) { + watcher := newWatcher() + serviceEntry := newServiceEntry( + "node-name", "node-address", "service-name", "v1.0.0", + []*api.HealthCheck{ + newHealthCheck("node-name", "service-name", "critical"), + }, + ) + + watcher.serviceHandler(1234, []*api.ServiceEntry{serviceEntry}) + + if len(watcher.services["service-name"][0].Nodes) != 0 { + t.Errorf("Expected length of the service nodes to be 0") + } +} + +func TestUnhealthyNodeServiceHandler(t *testing.T) { + watcher := newWatcher() + serviceEntry := newServiceEntry( + "node-name", "node-address", "service-name", "v1.0.0", + []*api.HealthCheck{ + newHealthCheck("node-name", "service-name", "passing"), + newHealthCheck("node-name", "serfHealth", "critical"), + }, + ) + + watcher.serviceHandler(1234, []*api.ServiceEntry{serviceEntry}) + + if len(watcher.services["service-name"][0].Nodes) != 0 { + t.Errorf("Expected length of the service nodes to be 0") + } +} + +func newWatcher() *consulWatcher { + return &consulWatcher{ + exit: make(chan bool), + next: make(chan *registry.Result, 10), + services: make(map[string][]*registry.Service), + } +} + +func newHealthCheck(node, name, status string) *api.HealthCheck { + return &api.HealthCheck{ + Node: node, + Name: name, + Status: status, + ServiceName: name, + } +} + +func newServiceEntry(node, address, name, version string, checks []*api.HealthCheck) *api.ServiceEntry { + return &api.ServiceEntry{ + Node: &api.Node{Node: node, Address: name}, + Service: &api.AgentService{ + Service: name, + Address: address, + Tags: encodeVersion(version), + }, + Checks: checks, + } +} diff --git a/registry/etcd/etcd.go b/registry/etcd/etcd.go new file mode 100644 index 00000000..8fb6bc68 --- /dev/null +++ b/registry/etcd/etcd.go @@ -0,0 +1,418 @@ +// Package etcd provides an etcd service registry +package etcd + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "net" + "os" + "path" + "sort" + "strings" + "sync" + "time" + + hash "github.com/mitchellh/hashstructure" + "go-micro.dev/v5/logger" + "go-micro.dev/v5/registry" + "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" + clientv3 "go.etcd.io/etcd/client/v3" + "go.uber.org/zap" +) + +var ( + prefix = "/micro/registry/" +) + +type etcdRegistry struct { + client *clientv3.Client + options registry.Options + + sync.RWMutex + register map[string]uint64 + leases map[string]clientv3.LeaseID +} + +func NewEtcdRegistry(opts ...registry.Option) registry.Registry { + e := &etcdRegistry{ + options: registry.Options{}, + register: make(map[string]uint64), + leases: make(map[string]clientv3.LeaseID), + } + username, password := os.Getenv("ETCD_USERNAME"), os.Getenv("ETCD_PASSWORD") + if len(username) > 0 && len(password) > 0 { + opts = append(opts, Auth(username, password)) + } + address := os.Getenv("MICRO_REGISTRY_ADDRESS") + if len(address) > 0 { + opts = append(opts, registry.Addrs(address)) + } + configure(e, opts...) + return e +} + +func configure(e *etcdRegistry, opts ...registry.Option) error { + config := clientv3.Config{ + Endpoints: []string{"127.0.0.1:2379"}, + } + + for _, o := range opts { + o(&e.options) + } + + if e.options.Timeout == 0 { + e.options.Timeout = 5 * time.Second + } + + if e.options.Logger == nil { + e.options.Logger = logger.DefaultLogger + } + + config.DialTimeout = e.options.Timeout + + if e.options.Secure || e.options.TLSConfig != nil { + tlsConfig := e.options.TLSConfig + if tlsConfig == nil { + tlsConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + config.TLS = tlsConfig + } + + if e.options.Context != nil { + u, ok := e.options.Context.Value(authKey{}).(*authCreds) + if ok { + config.Username = u.Username + config.Password = u.Password + } + cfg, ok := e.options.Context.Value(logConfigKey{}).(*zap.Config) + if ok && cfg != nil { + config.LogConfig = cfg + } + } + + var cAddrs []string + + for _, address := range e.options.Addrs { + if len(address) == 0 { + continue + } + addr, port, err := net.SplitHostPort(address) + if ae, ok := err.(*net.AddrError); ok && ae.Err == "missing port in address" { + port = "2379" + addr = address + cAddrs = append(cAddrs, net.JoinHostPort(addr, port)) + } else if err == nil { + cAddrs = append(cAddrs, net.JoinHostPort(addr, port)) + } + } + + // if we got addrs then we'll update + if len(cAddrs) > 0 { + config.Endpoints = cAddrs + } + + cli, err := clientv3.New(config) + if err != nil { + return err + } + e.client = cli + return nil +} + +func encode(s *registry.Service) string { + b, _ := json.Marshal(s) + return string(b) +} + +func decode(ds []byte) *registry.Service { + var s *registry.Service + json.Unmarshal(ds, &s) + return s +} + +func nodePath(s, id string) string { + service := strings.Replace(s, "/", "-", -1) + node := strings.Replace(id, "/", "-", -1) + return path.Join(prefix, service, node) +} + +func servicePath(s string) string { + return path.Join(prefix, strings.Replace(s, "/", "-", -1)) +} + +func (e *etcdRegistry) Init(opts ...registry.Option) error { + return configure(e, opts...) +} + +func (e *etcdRegistry) Options() registry.Options { + return e.options +} + +func (e *etcdRegistry) registerNode(s *registry.Service, node *registry.Node, opts ...registry.RegisterOption) error { + if len(s.Nodes) == 0 { + return errors.New("Require at least one node") + } + + // check existing lease cache + e.RLock() + leaseID, ok := e.leases[s.Name+node.Id] + e.RUnlock() + + log := e.options.Logger + + if !ok { + // missing lease, check if the key exists + ctx, cancel := context.WithTimeout(context.Background(), e.options.Timeout) + defer cancel() + + // look for the existing key + rsp, err := e.client.Get(ctx, nodePath(s.Name, node.Id), clientv3.WithSerializable()) + if err != nil { + return err + } + + // get the existing lease + for _, kv := range rsp.Kvs { + if kv.Lease > 0 { + leaseID = clientv3.LeaseID(kv.Lease) + + // decode the existing node + srv := decode(kv.Value) + if srv == nil || len(srv.Nodes) == 0 { + continue + } + + // create hash of service; uint64 + h, err := hash.Hash(srv.Nodes[0], nil) + if err != nil { + continue + } + + // save the info + e.Lock() + e.leases[s.Name+node.Id] = leaseID + e.register[s.Name+node.Id] = h + e.Unlock() + + break + } + } + } + + var leaseNotFound bool + + // renew the lease if it exists + if leaseID > 0 { + log.Logf(logger.TraceLevel, "Renewing existing lease for %s %d", s.Name, leaseID) + if _, err := e.client.KeepAliveOnce(context.TODO(), leaseID); err != nil { + if err != rpctypes.ErrLeaseNotFound { + return err + } + + log.Logf(logger.TraceLevel, "Lease not found for %s %d", s.Name, leaseID) + // lease not found do register + leaseNotFound = true + } + } + + // create hash of service; uint64 + h, err := hash.Hash(node, nil) + if err != nil { + return err + } + + // get existing hash for the service node + e.Lock() + v, ok := e.register[s.Name+node.Id] + e.Unlock() + + // the service is unchanged, skip registering + if ok && v == h && !leaseNotFound { + log.Logf(logger.TraceLevel, "Service %s node %s unchanged skipping registration", s.Name, node.Id) + return nil + } + + service := ®istry.Service{ + Name: s.Name, + Version: s.Version, + Metadata: s.Metadata, + Endpoints: s.Endpoints, + Nodes: []*registry.Node{node}, + } + + var options registry.RegisterOptions + for _, o := range opts { + o(&options) + } + + ctx, cancel := context.WithTimeout(context.Background(), e.options.Timeout) + defer cancel() + + var lgr *clientv3.LeaseGrantResponse + if options.TTL.Seconds() > 0 { + // get a lease used to expire keys since we have a ttl + lgr, err = e.client.Grant(ctx, int64(options.TTL.Seconds())) + if err != nil { + return err + } + } + + log.Logf(logger.TraceLevel, "Registering %s id %s with lease %v and leaseID %v and ttl %v", service.Name, node.Id, lgr, lgr.ID, options.TTL) + // create an entry for the node + if lgr != nil { + _, err = e.client.Put(ctx, nodePath(service.Name, node.Id), encode(service), clientv3.WithLease(lgr.ID)) + } else { + _, err = e.client.Put(ctx, nodePath(service.Name, node.Id), encode(service)) + } + if err != nil { + return err + } + + e.Lock() + // save our hash of the service + e.register[s.Name+node.Id] = h + // save our leaseID of the service + if lgr != nil { + e.leases[s.Name+node.Id] = lgr.ID + } + e.Unlock() + + return nil +} + +func (e *etcdRegistry) Deregister(s *registry.Service, opts ...registry.DeregisterOption) error { + if len(s.Nodes) == 0 { + return errors.New("Require at least one node") + } + + for _, node := range s.Nodes { + e.Lock() + // delete our hash of the service + delete(e.register, s.Name+node.Id) + // delete our lease of the service + delete(e.leases, s.Name+node.Id) + e.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), e.options.Timeout) + defer cancel() + + e.options.Logger.Logf(logger.TraceLevel, "Deregistering %s id %s", s.Name, node.Id) + _, err := e.client.Delete(ctx, nodePath(s.Name, node.Id)) + if err != nil { + return err + } + } + + return nil +} + +func (e *etcdRegistry) Register(s *registry.Service, opts ...registry.RegisterOption) error { + if len(s.Nodes) == 0 { + return errors.New("Require at least one node") + } + + var gerr error + + // register each node individually + for _, node := range s.Nodes { + err := e.registerNode(s, node, opts...) + if err != nil { + gerr = err + } + } + + return gerr +} + +func (e *etcdRegistry) GetService(name string, opts ...registry.GetOption) ([]*registry.Service, error) { + ctx, cancel := context.WithTimeout(context.Background(), e.options.Timeout) + defer cancel() + + rsp, err := e.client.Get(ctx, servicePath(name)+"/", clientv3.WithPrefix(), clientv3.WithSerializable()) + if err != nil { + return nil, err + } + + if len(rsp.Kvs) == 0 { + return nil, registry.ErrNotFound + } + + serviceMap := map[string]*registry.Service{} + + for _, n := range rsp.Kvs { + if sn := decode(n.Value); sn != nil { + s, ok := serviceMap[sn.Version] + if !ok { + s = ®istry.Service{ + Name: sn.Name, + Version: sn.Version, + Metadata: sn.Metadata, + Endpoints: sn.Endpoints, + } + serviceMap[s.Version] = s + } + + s.Nodes = append(s.Nodes, sn.Nodes...) + } + } + + services := make([]*registry.Service, 0, len(serviceMap)) + for _, service := range serviceMap { + services = append(services, service) + } + + return services, nil +} + +func (e *etcdRegistry) ListServices(opts ...registry.ListOption) ([]*registry.Service, error) { + versions := make(map[string]*registry.Service) + + ctx, cancel := context.WithTimeout(context.Background(), e.options.Timeout) + defer cancel() + + rsp, err := e.client.Get(ctx, prefix, clientv3.WithPrefix(), clientv3.WithSerializable()) + if err != nil { + return nil, err + } + + if len(rsp.Kvs) == 0 { + return []*registry.Service{}, nil + } + + for _, n := range rsp.Kvs { + sn := decode(n.Value) + if sn == nil { + continue + } + v, ok := versions[sn.Name+sn.Version] + if !ok { + versions[sn.Name+sn.Version] = sn + continue + } + // append to service:version nodes + v.Nodes = append(v.Nodes, sn.Nodes...) + } + + services := make([]*registry.Service, 0, len(versions)) + for _, service := range versions { + services = append(services, service) + } + + // sort the services + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + + return services, nil +} + +func (e *etcdRegistry) Watch(opts ...registry.WatchOption) (registry.Watcher, error) { + return newEtcdWatcher(e, e.options.Timeout, opts...) +} + +func (e *etcdRegistry) String() string { + return "etcd" +} diff --git a/registry/etcd/options.go b/registry/etcd/options.go new file mode 100644 index 00000000..b1a55799 --- /dev/null +++ b/registry/etcd/options.go @@ -0,0 +1,37 @@ +package etcd + +import ( + "context" + + "go-micro.dev/v5/registry" + "go.uber.org/zap" +) + +type authKey struct{} + +type logConfigKey struct{} + +type authCreds struct { + Username string + Password string +} + +// Auth allows you to specify username/password. +func Auth(username, password string) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, authKey{}, &authCreds{Username: username, Password: password}) + } +} + +// LogConfig allows you to set etcd log config. +func LogConfig(config *zap.Config) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, logConfigKey{}, config) + } +} diff --git a/registry/etcd/watcher.go b/registry/etcd/watcher.go new file mode 100644 index 00000000..5d6e1f75 --- /dev/null +++ b/registry/etcd/watcher.go @@ -0,0 +1,91 @@ +package etcd + +import ( + "context" + "errors" + "time" + + "go-micro.dev/v5/registry" + clientv3 "go.etcd.io/etcd/client/v3" +) + +type etcdWatcher struct { + stop chan bool + w clientv3.WatchChan + client *clientv3.Client + timeout time.Duration +} + +func newEtcdWatcher(r *etcdRegistry, timeout time.Duration, opts ...registry.WatchOption) (registry.Watcher, error) { + var wo registry.WatchOptions + for _, o := range opts { + o(&wo) + } + + ctx, cancel := context.WithCancel(context.Background()) + stop := make(chan bool, 1) + + go func() { + <-stop + cancel() + }() + + watchPath := prefix + if len(wo.Service) > 0 { + watchPath = servicePath(wo.Service) + "/" + } + + return &etcdWatcher{ + stop: stop, + w: r.client.Watch(ctx, watchPath, clientv3.WithPrefix(), clientv3.WithPrevKV()), + client: r.client, + timeout: timeout, + }, nil +} + +func (ew *etcdWatcher) Next() (*registry.Result, error) { + for wresp := range ew.w { + if wresp.Err() != nil { + return nil, wresp.Err() + } + if wresp.Canceled { + return nil, errors.New("could not get next") + } + for _, ev := range wresp.Events { + service := decode(ev.Kv.Value) + var action string + + switch ev.Type { + case clientv3.EventTypePut: + if ev.IsCreate() { + action = "create" + } else if ev.IsModify() { + action = "update" + } + case clientv3.EventTypeDelete: + action = "delete" + + // get service from prevKv + service = decode(ev.PrevKv.Value) + } + + if service == nil { + continue + } + return ®istry.Result{ + Action: action, + Service: service, + }, nil + } + } + return nil, errors.New("could not get next") +} + +func (ew *etcdWatcher) Stop() { + select { + case <-ew.stop: + return + default: + close(ew.stop) + } +} diff --git a/registry/mdns/mdns.go b/registry/mdns/mdns.go new file mode 100644 index 00000000..cb4a37e3 --- /dev/null +++ b/registry/mdns/mdns.go @@ -0,0 +1,5 @@ +package mdns + +var ( + DefaultRegistry = NewMDNSRegistry() +) diff --git a/registry/mdns_registry.go b/registry/mdns/mdns_registry.go similarity index 87% rename from registry/mdns_registry.go rename to registry/mdns/mdns_registry.go index a02ab009..2e66a228 100644 --- a/registry/mdns_registry.go +++ b/registry/mdns/mdns_registry.go @@ -1,5 +1,5 @@ // Package mdns is a multicast dns registry -package registry +package mdns import ( "bytes" @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" log "go-micro.dev/v5/logger" + "go-micro.dev/v5/registry" "go-micro.dev/v5/util/mdns" ) @@ -29,7 +30,7 @@ type mdnsTxt struct { Metadata map[string]string Service string Version string - Endpoints []*Endpoint + Endpoints []*registry.Endpoint } type mdnsEntry struct { @@ -38,7 +39,7 @@ type mdnsEntry struct { } type mdnsRegistry struct { - opts *Options + opts *registry.Options services map[string][]*mdnsEntry // watchers @@ -55,7 +56,7 @@ type mdnsRegistry struct { } type mdnsWatcher struct { - wo WatchOptions + wo registry.WatchOptions ch chan *mdns.ServiceEntry exit chan struct{} // the registry @@ -127,9 +128,9 @@ func decode(record []string) (*mdnsTxt, error) { return txt, nil } -func newRegistry(opts ...Option) Registry { - mergedOpts := append([]Option{Timeout(time.Millisecond * 100)}, opts...) - options := NewOptions(mergedOpts...) +func newRegistry(opts ...registry.Option) registry.Registry { + mergedOpts := append([]registry.Option{registry.Timeout(time.Millisecond * 100)}, opts...) + options := registry.NewOptions(mergedOpts...) // set the domain domain := mdnsDomain @@ -147,18 +148,18 @@ func newRegistry(opts ...Option) Registry { } } -func (m *mdnsRegistry) Init(opts ...Option) error { +func (m *mdnsRegistry) Init(opts ...registry.Option) error { for _, o := range opts { o(m.opts) } return nil } -func (m *mdnsRegistry) Options() Options { +func (m *mdnsRegistry) Options() registry.Options { return *m.opts } -func (m *mdnsRegistry) Register(service *Service, opts ...RegisterOption) error { +func (m *mdnsRegistry) Register(service *registry.Service, opts ...registry.RegisterOption) error { m.Lock() defer m.Unlock() @@ -263,7 +264,7 @@ func (m *mdnsRegistry) Register(service *Service, opts ...RegisterOption) error return gerr } -func (m *mdnsRegistry) Deregister(service *Service, opts ...DeregisterOption) error { +func (m *mdnsRegistry) Deregister(service *registry.Service, opts ...registry.DeregisterOption) error { m.Lock() defer m.Unlock() @@ -298,9 +299,9 @@ func (m *mdnsRegistry) Deregister(service *Service, opts ...DeregisterOption) er return nil } -func (m *mdnsRegistry) GetService(service string, opts ...GetOption) ([]*Service, error) { +func (m *mdnsRegistry) GetService(service string, opts ...registry.GetOption) ([]*registry.Service, error) { logger := m.opts.Logger - serviceMap := make(map[string]*Service) + serviceMap := make(map[string]*registry.Service) entries := make(chan *mdns.ServiceEntry, 10) done := make(chan bool) @@ -340,7 +341,7 @@ func (m *mdnsRegistry) GetService(service string, opts ...GetOption) ([]*Service s, ok := serviceMap[txt.Version] if !ok { - s = &Service{ + s = ®istry.Service{ Name: txt.Service, Version: txt.Version, Endpoints: txt.Endpoints, @@ -357,7 +358,7 @@ func (m *mdnsRegistry) GetService(service string, opts ...GetOption) ([]*Service logger.Logf(log.InfoLevel, "[mdns]: invalid endpoint received: %v", e) continue } - s.Nodes = append(s.Nodes, &Node{ + s.Nodes = append(s.Nodes, ®istry.Node{ Id: strings.TrimSuffix(e.Name, "."+p.Service+"."+p.Domain+"."), Address: addr, Metadata: txt.Metadata, @@ -380,7 +381,7 @@ func (m *mdnsRegistry) GetService(service string, opts ...GetOption) ([]*Service <-done // create list and return - services := make([]*Service, 0, len(serviceMap)) + services := make([]*registry.Service, 0, len(serviceMap)) for _, service := range serviceMap { services = append(services, service) @@ -389,7 +390,7 @@ func (m *mdnsRegistry) GetService(service string, opts ...GetOption) ([]*Service return services, nil } -func (m *mdnsRegistry) ListServices(opts ...ListOption) ([]*Service, error) { +func (m *mdnsRegistry) ListServices(opts ...registry.ListOption) ([]*registry.Service, error) { serviceMap := make(map[string]bool) entries := make(chan *mdns.ServiceEntry, 10) done := make(chan bool) @@ -404,7 +405,7 @@ func (m *mdnsRegistry) ListServices(opts ...ListOption) ([]*Service, error) { // set domain p.Domain = m.domain - var services []*Service + var services []*registry.Service go func() { for { @@ -419,7 +420,7 @@ func (m *mdnsRegistry) ListServices(opts ...ListOption) ([]*Service, error) { name := strings.TrimSuffix(e.Name, "."+p.Service+"."+p.Domain+".") if !serviceMap[name] { serviceMap[name] = true - services = append(services, &Service{Name: name}) + services = append(services, ®istry.Service{Name: name}) } case <-p.Context.Done(): close(done) @@ -439,8 +440,8 @@ func (m *mdnsRegistry) ListServices(opts ...ListOption) ([]*Service, error) { return services, nil } -func (m *mdnsRegistry) Watch(opts ...WatchOption) (Watcher, error) { - var wo WatchOptions +func (m *mdnsRegistry) Watch(opts ...registry.WatchOption) (registry.Watcher, error) { + var wo registry.WatchOptions for _, o := range opts { o(&wo) } @@ -537,7 +538,7 @@ func (m *mdnsRegistry) String() string { return "mdns" } -func (m *mdnsWatcher) Next() (*Result, error) { +func (m *mdnsWatcher) Next() (*registry.Result, error) { for { select { case e := <-m.ch: @@ -562,7 +563,7 @@ func (m *mdnsWatcher) Next() (*Result, error) { action = "create" } - service := &Service{ + service := ®istry.Service{ Name: txt.Service, Version: txt.Version, Endpoints: txt.Endpoints, @@ -583,18 +584,18 @@ func (m *mdnsWatcher) Next() (*Result, error) { addr = e.Addr.String() } - service.Nodes = append(service.Nodes, &Node{ + service.Nodes = append(service.Nodes, ®istry.Node{ Id: strings.TrimSuffix(e.Name, suffix), Address: addr, Metadata: txt.Metadata, }) - return &Result{ + return ®istry.Result{ Action: action, Service: service, }, nil case <-m.exit: - return nil, ErrWatcherStopped + return nil, registry.ErrWatcherStopped } } } @@ -613,6 +614,6 @@ func (m *mdnsWatcher) Stop() { } // NewRegistry returns a new default registry which is mdns. -func NewRegistry(opts ...Option) Registry { +func NewMDNSRegistry(opts ...registry.Option) registry.Registry { return newRegistry(opts...) } diff --git a/registry/mdns_test.go b/registry/mdns/mdns_test.go similarity index 89% rename from registry/mdns_test.go rename to registry/mdns/mdns_test.go index de5a6bca..28745ac6 100644 --- a/registry/mdns_test.go +++ b/registry/mdns/mdns_test.go @@ -1,9 +1,11 @@ -package registry +package mdns import ( "os" "testing" "time" + + "go-micro.dev/v5/registry" ) func TestMDNS(t *testing.T) { @@ -12,11 +14,11 @@ func TestMDNS(t *testing.T) { t.Skip() } - testData := []*Service{ + testData := []*registry.Service{ { Name: "test1", Version: "1.0.1", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test1-1", Address: "10.0.0.1:10001", @@ -29,7 +31,7 @@ func TestMDNS(t *testing.T) { { Name: "test2", Version: "1.0.2", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test2-1", Address: "10.0.0.2:10002", @@ -42,7 +44,7 @@ func TestMDNS(t *testing.T) { { Name: "test3", Version: "1.0.3", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test3-1", Address: "10.0.0.3:10003", @@ -55,7 +57,7 @@ func TestMDNS(t *testing.T) { { Name: "test4", Version: "1.0.4", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test4-1", Address: "[::]:10004", @@ -69,14 +71,14 @@ func TestMDNS(t *testing.T) { travis := os.Getenv("TRAVIS") - var opts []Option + var opts []registry.Option if travis == "true" { - opts = append(opts, Timeout(time.Millisecond*100)) + opts = append(opts, registry.Timeout(time.Millisecond*100)) } // new registry - r := NewRegistry(opts...) + r := NewMDNSRegistry(opts...) for _, service := range testData { // register service @@ -156,14 +158,14 @@ func TestEncoding(t *testing.T) { Metadata: map[string]string{ "foo": "bar", }, - Endpoints: []*Endpoint{ + Endpoints: []*registry.Endpoint{ { Name: "endpoint1", - Request: &Value{ + Request: ®istry.Value{ Name: "request", Type: "request", }, - Response: &Value{ + Response: ®istry.Value{ Name: "response", Type: "response", }, @@ -213,11 +215,11 @@ func TestWatcher(t *testing.T) { t.Skip() } - testData := []*Service{ + testData := []*registry.Service{ { Name: "test1", Version: "1.0.1", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test1-1", Address: "10.0.0.1:10001", @@ -230,7 +232,7 @@ func TestWatcher(t *testing.T) { { Name: "test2", Version: "1.0.2", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test2-1", Address: "10.0.0.2:10002", @@ -243,7 +245,7 @@ func TestWatcher(t *testing.T) { { Name: "test3", Version: "1.0.3", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test3-1", Address: "10.0.0.3:10003", @@ -256,7 +258,7 @@ func TestWatcher(t *testing.T) { { Name: "test4", Version: "1.0.4", - Nodes: []*Node{ + Nodes: []*registry.Node{ { Id: "test4-1", Address: "[::]:10004", @@ -268,7 +270,7 @@ func TestWatcher(t *testing.T) { }, } - testFn := func(service, s *Service) { + testFn := func(service, s *registry.Service) { if s == nil { t.Fatalf("Expected one result for %s got nil", service.Name) } @@ -298,14 +300,14 @@ func TestWatcher(t *testing.T) { travis := os.Getenv("TRAVIS") - var opts []Option + var opts []registry.Option if travis == "true" { - opts = append(opts, Timeout(time.Millisecond*100)) + opts = append(opts, registry.Timeout(time.Millisecond*100)) } // new registry - r := NewRegistry(opts...) + r := NewMDNSRegistry(opts...) w, err := r.Watch() if err != nil { diff --git a/registry/nats/nats.go b/registry/nats/nats.go new file mode 100644 index 00000000..d768a93b --- /dev/null +++ b/registry/nats/nats.go @@ -0,0 +1,417 @@ +// Package nats provides a NATS registry using broadcast queries +package nats + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go" + "go-micro.dev/v5/registry" +) + +type natsRegistry struct { + addrs []string + opts registry.Options + nopts nats.Options + queryTopic string + watchTopic string + registerAction string + + sync.RWMutex + conn *nats.Conn + services map[string][]*registry.Service + listeners map[string]chan bool +} + +var ( + defaultQueryTopic = "micro.nats.query" + defaultWatchTopic = "micro.nats.watch" + defaultRegisterAction = "create" +) + +func configure(n *natsRegistry, opts ...registry.Option) error { + for _, o := range opts { + o(&n.opts) + } + + natsOptions := nats.GetDefaultOptions() + if n, ok := n.opts.Context.Value(optionsKey{}).(nats.Options); ok { + natsOptions = n + } + + queryTopic := defaultQueryTopic + if qt, ok := n.opts.Context.Value(queryTopicKey{}).(string); ok { + queryTopic = qt + } + + watchTopic := defaultWatchTopic + if wt, ok := n.opts.Context.Value(watchTopicKey{}).(string); ok { + watchTopic = wt + } + + registerAction := defaultRegisterAction + if ra, ok := n.opts.Context.Value(registerActionKey{}).(string); ok { + registerAction = ra + } + + // Options have higher priority than nats.Options + // only if Addrs, Secure or TLSConfig were not set through a Option + // we read them from nats.Option + if len(n.opts.Addrs) == 0 { + n.opts.Addrs = natsOptions.Servers + } + + if !n.opts.Secure { + n.opts.Secure = natsOptions.Secure + } + + if n.opts.TLSConfig == nil { + n.opts.TLSConfig = natsOptions.TLSConfig + } + + // check & add nats:// prefix (this makes also sure that the addresses + // stored in natsaddrs and n.opts.Addrs are identical) + n.opts.Addrs = setAddrs(n.opts.Addrs) + + n.addrs = n.opts.Addrs + n.nopts = natsOptions + n.queryTopic = queryTopic + n.watchTopic = watchTopic + n.registerAction = registerAction + + return nil +} + +func setAddrs(addrs []string) []string { + var cAddrs []string + for _, addr := range addrs { + if len(addr) == 0 { + continue + } + if !strings.HasPrefix(addr, "nats://") { + addr = "nats://" + addr + } + cAddrs = append(cAddrs, addr) + } + if len(cAddrs) == 0 { + cAddrs = []string{nats.DefaultURL} + } + return cAddrs +} + +func (n *natsRegistry) newConn() (*nats.Conn, error) { + opts := n.nopts + opts.Servers = n.addrs + opts.Secure = n.opts.Secure + opts.TLSConfig = n.opts.TLSConfig + + // secure might not be set + if opts.TLSConfig != nil { + opts.Secure = true + } + + return opts.Connect() +} + +func (n *natsRegistry) getConn() (*nats.Conn, error) { + n.Lock() + defer n.Unlock() + + if n.conn != nil { + return n.conn, nil + } + + c, err := n.newConn() + if err != nil { + return nil, err + } + n.conn = c + + return n.conn, nil +} + +func (n *natsRegistry) register(s *registry.Service) error { + conn, err := n.getConn() + if err != nil { + return err + } + + n.Lock() + defer n.Unlock() + + // cache service + n.services[s.Name] = addServices(n.services[s.Name], cp([]*registry.Service{s})) + + // create query listener + if n.listeners[s.Name] == nil { + listener := make(chan bool) + + // create a subscriber that responds to queries + sub, err := conn.Subscribe(n.queryTopic, func(m *nats.Msg) { + var result *registry.Result + + if err := json.Unmarshal(m.Data, &result); err != nil { + return + } + + var services []*registry.Service + + switch result.Action { + // is this a get query and we own the service? + case "get": + if result.Service.Name != s.Name { + return + } + n.RLock() + services = cp(n.services[s.Name]) + n.RUnlock() + // it's a list request, but we're still only a + // subscriber for this service... so just get this service + // totally suboptimal + case "list": + n.RLock() + services = cp(n.services[s.Name]) + n.RUnlock() + default: + // does not match + return + } + + // respond to query + for _, service := range services { + b, err := json.Marshal(service) + if err != nil { + continue + } + conn.Publish(m.Reply, b) + } + }) + if err != nil { + return err + } + + // Unsubscribe if we're told to do so + go func() { + <-listener + sub.Unsubscribe() + }() + + n.listeners[s.Name] = listener + } + + return nil +} + +func (n *natsRegistry) deregister(s *registry.Service) error { + n.Lock() + defer n.Unlock() + + services := delServices(n.services[s.Name], cp([]*registry.Service{s})) + if len(services) > 0 { + n.services[s.Name] = services + return nil + } + + // delete cached service + delete(n.services, s.Name) + + // delete query listener + if listener, lexists := n.listeners[s.Name]; lexists { + close(listener) + delete(n.listeners, s.Name) + } + + return nil +} + +func (n *natsRegistry) query(s string, quorum int) ([]*registry.Service, error) { + conn, err := n.getConn() + if err != nil { + return nil, err + } + + var action string + var service *registry.Service + + if len(s) > 0 { + action = "get" + service = ®istry.Service{Name: s} + } else { + action = "list" + } + + inbox := nats.NewInbox() + + response := make(chan *registry.Service, 10) + + sub, err := conn.Subscribe(inbox, func(m *nats.Msg) { + var service *registry.Service + if err := json.Unmarshal(m.Data, &service); err != nil { + return + } + select { + case response <- service: + case <-time.After(n.opts.Timeout): + } + }) + if err != nil { + return nil, err + } + defer sub.Unsubscribe() + + b, err := json.Marshal(®istry.Result{Action: action, Service: service}) + if err != nil { + return nil, err + } + + if err := conn.PublishMsg(&nats.Msg{ + Subject: n.queryTopic, + Reply: inbox, + Data: b, + }); err != nil { + return nil, err + } + + timeoutChan := time.After(n.opts.Timeout) + + serviceMap := make(map[string]*registry.Service) + +loop: + for { + select { + case service := <-response: + key := service.Name + "-" + service.Version + srv, ok := serviceMap[key] + if ok { + srv.Nodes = append(srv.Nodes, service.Nodes...) + serviceMap[key] = srv + } else { + serviceMap[key] = service + } + + if quorum > 0 && len(serviceMap[key].Nodes) >= quorum { + break loop + } + case <-timeoutChan: + break loop + } + } + + var services []*registry.Service + for _, service := range serviceMap { + services = append(services, service) + } + return services, nil +} + +func (n *natsRegistry) Init(opts ...registry.Option) error { + return configure(n, opts...) +} + +func (n *natsRegistry) Options() registry.Options { + return n.opts +} + +func (n *natsRegistry) Register(s *registry.Service, opts ...registry.RegisterOption) error { + if err := n.register(s); err != nil { + return err + } + + conn, err := n.getConn() + if err != nil { + return err + } + + b, err := json.Marshal(®istry.Result{Action: n.registerAction, Service: s}) + if err != nil { + return err + } + + return conn.Publish(n.watchTopic, b) +} + +func (n *natsRegistry) Deregister(s *registry.Service, opts ...registry.DeregisterOption) error { + if err := n.deregister(s); err != nil { + return err + } + + conn, err := n.getConn() + if err != nil { + return err + } + + b, err := json.Marshal(®istry.Result{Action: "delete", Service: s}) + if err != nil { + return err + } + return conn.Publish(n.watchTopic, b) +} + +func (n *natsRegistry) GetService(s string, opts ...registry.GetOption) ([]*registry.Service, error) { + services, err := n.query(s, getQuorum(n.opts)) + if err != nil { + return nil, err + } + return services, nil +} + +func (n *natsRegistry) ListServices(opts ...registry.ListOption) ([]*registry.Service, error) { + s, err := n.query("", 0) + if err != nil { + return nil, err + } + + var services []*registry.Service + serviceMap := make(map[string]*registry.Service) + + for _, v := range s { + serviceMap[v.Name] = ®istry.Service{Name: v.Name, Version: v.Version} + } + + for _, v := range serviceMap { + services = append(services, v) + } + + return services, nil +} + +func (n *natsRegistry) Watch(opts ...registry.WatchOption) (registry.Watcher, error) { + conn, err := n.getConn() + if err != nil { + return nil, err + } + + sub, err := conn.SubscribeSync(n.watchTopic) + if err != nil { + return nil, err + } + + var wo registry.WatchOptions + for _, o := range opts { + o(&wo) + } + + return &natsWatcher{sub, wo}, nil +} + +func (n *natsRegistry) String() string { + return "nats" +} + +func NewNatsRegistry(opts ...registry.Option) registry.Registry { + options := registry.Options{ + Timeout: time.Millisecond * 100, + Context: context.Background(), + } + + n := &natsRegistry{ + opts: options, + services: make(map[string][]*registry.Service), + listeners: make(map[string]chan bool), + } + configure(n, opts...) + return n +} diff --git a/registry/nats/nats_assert_test.go b/registry/nats/nats_assert_test.go new file mode 100644 index 00000000..c74bec38 --- /dev/null +++ b/registry/nats/nats_assert_test.go @@ -0,0 +1,18 @@ +package nats_test + +import ( + "reflect" + "testing" +) + +func assertNoError(tb testing.TB, actual error) { + if actual != nil { + tb.Errorf("expected no error, got %v", actual) + } +} + +func assertEqual(tb testing.TB, expected, actual interface{}) { + if !reflect.DeepEqual(expected, actual) { + tb.Errorf("expected %v, got %v", expected, actual) + } +} diff --git a/registry/nats/nats_environment_test.go b/registry/nats/nats_environment_test.go new file mode 100644 index 00000000..c1fe2124 --- /dev/null +++ b/registry/nats/nats_environment_test.go @@ -0,0 +1,69 @@ +package nats_test + +import ( + "os" + "testing" + + log "go-micro.dev/v5/logger" + "go-micro.dev/v5/registry" + "go-micro.dev/v5/registry/nats" +) + +type environment struct { + registryOne registry.Registry + registryTwo registry.Registry + registryThree registry.Registry + + serviceOne registry.Service + serviceTwo registry.Service + + nodeOne registry.Node + nodeTwo registry.Node + nodeThree registry.Node +} + +var e environment + +func TestMain(m *testing.M) { + natsURL := os.Getenv("NATS_URL") + if natsURL == "" { + log.Infof("NATS_URL is undefined - skipping tests") + return + } + + e.registryOne = nats.NewNatsRegistry(registry.Addrs(natsURL), nats.Quorum(1)) + e.registryTwo = nats.NewNatsRegistry(registry.Addrs(natsURL), nats.Quorum(1)) + e.registryThree = nats.NewNatsRegistry(registry.Addrs(natsURL), nats.Quorum(1)) + + e.serviceOne.Name = "one" + e.serviceOne.Version = "default" + e.serviceOne.Nodes = []*registry.Node{&e.nodeOne} + + e.serviceTwo.Name = "two" + e.serviceTwo.Version = "default" + e.serviceTwo.Nodes = []*registry.Node{&e.nodeOne, &e.nodeTwo} + + e.nodeOne.Id = "one" + e.nodeTwo.Id = "two" + e.nodeThree.Id = "three" + + if err := e.registryOne.Register(&e.serviceOne); err != nil { + log.Fatal(err) + } + + if err := e.registryOne.Register(&e.serviceTwo); err != nil { + log.Fatal(err) + } + + result := m.Run() + + if err := e.registryOne.Deregister(&e.serviceOne); err != nil { + log.Fatal(err) + } + + if err := e.registryOne.Deregister(&e.serviceTwo); err != nil { + log.Fatal(err) + } + + os.Exit(result) +} diff --git a/registry/nats/nats_options.go b/registry/nats/nats_options.go new file mode 100644 index 00000000..b8ec85dd --- /dev/null +++ b/registry/nats/nats_options.go @@ -0,0 +1,87 @@ +package nats + +import ( + "context" + + "github.com/nats-io/nats.go" + "go-micro.dev/v5/registry" +) + +type contextQuorumKey struct{} +type optionsKey struct{} +type watchTopicKey struct{} +type queryTopicKey struct{} +type registerActionKey struct{} + +var ( + DefaultQuorum = 0 +) + +func getQuorum(o registry.Options) int { + if o.Context == nil { + return DefaultQuorum + } + + value := o.Context.Value(contextQuorumKey{}) + if v, ok := value.(int); ok { + return v + } else { + return DefaultQuorum + } +} + +func Quorum(n int) registry.Option { + return func(o *registry.Options) { + o.Context = context.WithValue(o.Context, contextQuorumKey{}, n) + } +} + +// Options allow to inject a nats.Options struct for configuring +// the nats connection. +func NatsOptions(nopts nats.Options) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, optionsKey{}, nopts) + } +} + +// QueryTopic allows to set a custom nats topic on which service registries +// query (survey) other services. All registries listen on this topic and +// then respond to the query message. +func QueryTopic(s string) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, queryTopicKey{}, s) + } +} + +// WatchTopic allows to set a custom nats topic on which registries broadcast +// changes (e.g. when services are added, updated or removed). Since we don't +// have a central registry service, each service typically broadcasts in a +// determined frequency on this topic. +func WatchTopic(s string) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, watchTopicKey{}, s) + } +} + +// RegisterAction allows to set the action to use when registering to nats. +// As of now there are three different options: +// - "create" (default) only registers if there is noone already registered under the same key. +// - "update" only updates the registration if it already exists. +// - "put" creates or updates a registration +func RegisterAction(s string) registry.Option { + return func(o *registry.Options) { + if o.Context == nil { + o.Context = context.Background() + } + o.Context = context.WithValue(o.Context, registerActionKey{}, s) + } +} diff --git a/registry/nats/nats_registry.go b/registry/nats/nats_registry.go new file mode 100644 index 00000000..3f280771 --- /dev/null +++ b/registry/nats/nats_registry.go @@ -0,0 +1,5 @@ +package nats + +var ( + DefaultRegistry = NewNatsRegistry() +) diff --git a/registry/nats/nats_test.go b/registry/nats/nats_test.go new file mode 100644 index 00000000..251ee66b --- /dev/null +++ b/registry/nats/nats_test.go @@ -0,0 +1,95 @@ +package nats_test + +import ( + "testing" + + "go-micro.dev/v5/registry" +) + +func TestRegister(t *testing.T) { + service := registry.Service{Name: "test"} + assertNoError(t, e.registryOne.Register(&service)) + defer e.registryOne.Deregister(&service) + + services, err := e.registryOne.ListServices() + assertNoError(t, err) + assertEqual(t, 3, len(services)) + + services, err = e.registryTwo.ListServices() + assertNoError(t, err) + assertEqual(t, 3, len(services)) +} + +func TestDeregister(t *testing.T) { + service1 := registry.Service{Name: "test-deregister", Version: "v1"} + service2 := registry.Service{Name: "test-deregister", Version: "v2"} + + assertNoError(t, e.registryOne.Register(&service1)) + services, err := e.registryOne.GetService(service1.Name) + assertNoError(t, err) + assertEqual(t, 1, len(services)) + + assertNoError(t, e.registryOne.Register(&service2)) + services, err = e.registryOne.GetService(service2.Name) + assertNoError(t, err) + assertEqual(t, 2, len(services)) + + assertNoError(t, e.registryOne.Deregister(&service1)) + services, err = e.registryOne.GetService(service1.Name) + assertNoError(t, err) + assertEqual(t, 1, len(services)) + + assertNoError(t, e.registryOne.Deregister(&service2)) + services, err = e.registryOne.GetService(service1.Name) + assertNoError(t, err) + assertEqual(t, 0, len(services)) +} + +func TestGetService(t *testing.T) { + services, err := e.registryTwo.GetService("one") + assertNoError(t, err) + assertEqual(t, 1, len(services)) + assertEqual(t, "one", services[0].Name) + assertEqual(t, 1, len(services[0].Nodes)) +} + +func TestGetServiceWithNoNodes(t *testing.T) { + services, err := e.registryOne.GetService("missing") + assertNoError(t, err) + assertEqual(t, 0, len(services)) +} + +func TestGetServiceFromMultipleNodes(t *testing.T) { + services, err := e.registryOne.GetService("two") + assertNoError(t, err) + assertEqual(t, 1, len(services)) + assertEqual(t, "two", services[0].Name) + assertEqual(t, 2, len(services[0].Nodes)) +} + +func BenchmarkGetService(b *testing.B) { + for n := 0; n < b.N; n++ { + services, err := e.registryTwo.GetService("one") + assertNoError(b, err) + assertEqual(b, 1, len(services)) + assertEqual(b, "one", services[0].Name) + } +} + +func BenchmarkGetServiceWithNoNodes(b *testing.B) { + for n := 0; n < b.N; n++ { + services, err := e.registryOne.GetService("missing") + assertNoError(b, err) + assertEqual(b, 0, len(services)) + } +} + +func BenchmarkGetServiceFromMultipleNodes(b *testing.B) { + for n := 0; n < b.N; n++ { + services, err := e.registryTwo.GetService("two") + assertNoError(b, err) + assertEqual(b, 1, len(services)) + assertEqual(b, "two", services[0].Name) + assertEqual(b, 2, len(services[0].Nodes)) + } +} diff --git a/registry/nats/nats_util.go b/registry/nats/nats_util.go new file mode 100644 index 00000000..83341712 --- /dev/null +++ b/registry/nats/nats_util.go @@ -0,0 +1,107 @@ +package nats + +import "go-micro.dev/v5/registry" + +func cp(current []*registry.Service) []*registry.Service { + var services []*registry.Service + + for _, service := range current { + // copy service + s := new(registry.Service) + *s = *service + + // copy nodes + var nodes []*registry.Node + for _, node := range service.Nodes { + n := new(registry.Node) + *n = *node + nodes = append(nodes, n) + } + s.Nodes = nodes + + // copy endpoints + var eps []*registry.Endpoint + for _, ep := range service.Endpoints { + e := new(registry.Endpoint) + *e = *ep + eps = append(eps, e) + } + s.Endpoints = eps + + // append service + services = append(services, s) + } + + return services +} + +func addNodes(old, neu []*registry.Node) []*registry.Node { + for _, n := range neu { + var seen bool + for i, o := range old { + if o.Id == n.Id { + seen = true + old[i] = n + break + } + } + if !seen { + old = append(old, n) + } + } + return old +} + +func addServices(old, neu []*registry.Service) []*registry.Service { + for _, s := range neu { + var seen bool + for i, o := range old { + if o.Version == s.Version { + s.Nodes = addNodes(o.Nodes, s.Nodes) + seen = true + old[i] = s + break + } + } + if !seen { + old = append(old, s) + } + } + return old +} + +func delNodes(old, del []*registry.Node) []*registry.Node { + var nodes []*registry.Node + for _, o := range old { + var rem bool + for _, n := range del { + if o.Id == n.Id { + rem = true + break + } + } + if !rem { + nodes = append(nodes, o) + } + } + return nodes +} + +func delServices(old, del []*registry.Service) []*registry.Service { + var services []*registry.Service + for i, o := range old { + var rem bool + for _, s := range del { + if o.Version == s.Version { + old[i].Nodes = delNodes(o.Nodes, s.Nodes) + if len(old[i].Nodes) == 0 { + rem = true + } + } + } + if !rem { + services = append(services, o) + } + } + return services +} diff --git a/registry/nats/nats_watcher.go b/registry/nats/nats_watcher.go new file mode 100644 index 00000000..6f2ef972 --- /dev/null +++ b/registry/nats/nats_watcher.go @@ -0,0 +1,39 @@ +package nats + +import ( + "encoding/json" + "time" + + "github.com/nats-io/nats.go" + "go-micro.dev/v5/registry" +) + +type natsWatcher struct { + sub *nats.Subscription + wo registry.WatchOptions +} + +func (n *natsWatcher) Next() (*registry.Result, error) { + var result *registry.Result + for { + m, err := n.sub.NextMsg(time.Minute) + if err != nil && err == nats.ErrTimeout { + continue + } else if err != nil { + return nil, err + } + if err := json.Unmarshal(m.Data, &result); err != nil { + return nil, err + } + if len(n.wo.Service) > 0 && result.Service.Name != n.wo.Service { + continue + } + break + } + + return result, nil +} + +func (n *natsWatcher) Stop() { + n.sub.Unsubscribe() +} diff --git a/registry/options_test.go b/registry/options_test.go new file mode 100644 index 00000000..aeb726c8 --- /dev/null +++ b/registry/options_test.go @@ -0,0 +1,166 @@ +//go:build nats +// +build nats + +package registry + +import ( + "fmt" + "log" + "os" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" +) + +var addrTestCases = []struct { + name string + description string + addrs map[string]string // expected address : set address +}{ + { + "registryOption", + "set registry addresses through a registry.Option in constructor", + map[string]string{ + "nats://192.168.10.1:5222": "192.168.10.1:5222", + "nats://10.20.10.0:4222": "10.20.10.0:4222"}, + }, + { + "natsOption", + "set registry addresses through the nats.Option in constructor", + map[string]string{ + "nats://192.168.10.1:5222": "192.168.10.1:5222", + "nats://10.20.10.0:4222": "10.20.10.0:4222"}, + }, + { + "default", + "check if default Address is set correctly", + map[string]string{ + nats.DefaultURL: "", + }, + }, +} + +func TestInitAddrs(t *testing.T) { + for _, tc := range addrTestCases { + t.Run(fmt.Sprintf("%s: %s", tc.name, tc.description), func(t *testing.T) { + var reg Registry + var addrs []string + + for _, addr := range tc.addrs { + addrs = append(addrs, addr) + } + + switch tc.name { + case "registryOption": + // we know that there are just two addrs in the dict + reg = NewRegistry(Addrs(addrs[0], addrs[1])) + case "natsOption": + nopts := nats.GetDefaultOptions() + nopts.Servers = addrs + reg = NewRegistry(Options(nopts)) + case "default": + reg = NewRegistry() + } + + // if err := reg.Register(dummyService); err != nil { + // t.Fatal(err) + // } + + natsRegistry, ok := reg.(*natsRegistry) + if !ok { + t.Fatal("Expected registry to be of types *natsRegistry") + } + // check if the same amount of addrs we set has actually been set + if len(natsRegistry.addrs) != len(tc.addrs) { + t.Errorf("Expected Addr = %v, Actual Addr = %v", + natsRegistry.addrs, tc.addrs) + t.Errorf("Expected Addr count = %d, Actual Addr count = %d", + len(natsRegistry.addrs), len(tc.addrs)) + } + + for _, addr := range natsRegistry.addrs { + _, ok := tc.addrs[addr] + if !ok { + t.Errorf("Expected Addr = %v, Actual Addr = %v", + natsRegistry.addrs, tc.addrs) + t.Errorf("Expected '%s' has not been set", addr) + } + } + }) + } +} + +func TestWatchQueryTopic(t *testing.T) { + natsURL := os.Getenv("NATS_URL") + if natsURL == "" { + log.Println("NATS_URL is undefined - skipping tests") + return + } + + watchTopic := "custom.test.watch" + queryTopic := "custom.test.query" + wt := WatchTopic(watchTopic) + qt := QueryTopic(queryTopic) + + // connect to NATS and subscribe to the Watch & Query topics where the + // registry will publish a msg + nopts := nats.GetDefaultOptions() + nopts.Servers = setAddrs([]string{natsURL}) + conn, err := nopts.Connect() + if err != nil { + t.Fatal(err) + } + + wg := sync.WaitGroup{} + wg.Add(2) + + okCh := make(chan struct{}) + + // Wait until we have received something on both topics + go func() { + wg.Wait() + close(okCh) + }() + + // handler just calls wg.Done() + rcvdHdlr := func(m *nats.Msg) { + wg.Done() + } + + _, err = conn.Subscribe(queryTopic, rcvdHdlr) + if err != nil { + t.Fatal(err) + } + + _, err = conn.Subscribe(watchTopic, rcvdHdlr) + if err != nil { + t.Fatal(err) + } + + dummyService := &Service{ + Name: "TestInitAddr", + Version: "1.0.0", + } + + reg := NewRegistry(qt, wt, Addrs(natsURL)) + + // trigger registry to send out message on watchTopic + if err := reg.Register(dummyService); err != nil { + t.Fatal(err) + } + + // trigger registry to send out message on queryTopic + if _, err := reg.ListServices(); err != nil { + t.Fatal(err) + } + + // make sure that we received something on tc.topic + select { + case <-okCh: + // fine - we received on both topics a message from the registry + case <-time.After(time.Millisecond * 200): + t.Fatal("timeout - no data received on watch topic") + } +} diff --git a/registry/registry.go b/registry/registry.go index 2ba5d56d..734730e1 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -6,8 +6,6 @@ import ( ) var ( - DefaultRegistry = NewRegistry() - // Not found error when GetService is called. ErrNotFound = errors.New("service not found") // Watcher stopped error when watcher is stopped. @@ -95,3 +93,7 @@ func Watch(opts ...WatchOption) (Watcher, error) { func String() string { return DefaultRegistry.String() } + +var ( + DefaultRegistry = NewMemoryRegistry() +) diff --git a/store/mysql/mysql.go b/store/mysql/mysql.go new file mode 100644 index 00000000..158d4500 --- /dev/null +++ b/store/mysql/mysql.go @@ -0,0 +1,238 @@ +package mysql + +import ( + "database/sql" + "fmt" + "time" + "unicode" + + "github.com/pkg/errors" + log "go-micro.dev/v5/logger" + "go-micro.dev/v5/store" +) + +var ( + // DefaultDatabase is the database that the sql store will use if no database is provided. + DefaultDatabase = "micro" + // DefaultTable is the table that the sql store will use if no table is provided. + DefaultTable = "micro" +) + +type sqlStore struct { + db *sql.DB + + database string + table string + + options store.Options + + readPrepare, writePrepare, deletePrepare *sql.Stmt +} + +func (s *sqlStore) Init(opts ...store.Option) error { + for _, o := range opts { + o(&s.options) + } + // reconfigure + return s.configure() +} + +func (s *sqlStore) Options() store.Options { + return s.options +} + +func (s *sqlStore) Close() error { + return s.db.Close() +} + +// List all the known records. +func (s *sqlStore) List(opts ...store.ListOption) ([]string, error) { + rows, err := s.db.Query(fmt.Sprintf("SELECT `key`, value, expiry FROM %s.%s;", s.database, s.table)) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + defer rows.Close() + + var records []string + var cachedTime time.Time + + for rows.Next() { + record := &store.Record{} + if err := rows.Scan(&record.Key, &record.Value, &cachedTime); err != nil { + return nil, err + } + + if cachedTime.Before(time.Now()) { + // record has expired + go s.Delete(record.Key) + } else { + records = append(records, record.Key) + } + } + rowErr := rows.Close() + if rowErr != nil { + // transaction rollback or something + return records, rowErr + } + if err := rows.Err(); err != nil { + return nil, err + } + return records, nil +} + +// Read all records with keys. +func (s *sqlStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) { + var options store.ReadOptions + for _, o := range opts { + o(&options) + } + + // TODO: make use of options.Prefix using WHERE key LIKE = ? + + var records []*store.Record + row := s.readPrepare.QueryRow(key) + record := &store.Record{} + var cachedTime time.Time + + if err := row.Scan(&record.Key, &record.Value, &cachedTime); err != nil { + if err == sql.ErrNoRows { + return records, store.ErrNotFound + } + return records, err + } + if cachedTime.Before(time.Now()) { + // record has expired + go s.Delete(key) + return records, store.ErrNotFound + } + record.Expiry = time.Until(cachedTime) + records = append(records, record) + + return records, nil +} + +// Write records. +func (s *sqlStore) Write(r *store.Record, opts ...store.WriteOption) error { + timeCached := time.Now().Add(r.Expiry) + _, err := s.writePrepare.Exec(r.Key, r.Value, timeCached, r.Value, timeCached) + if err != nil { + return errors.Wrap(err, "Couldn't insert record "+r.Key) + } + + return nil +} + +// Delete records with keys. +func (s *sqlStore) Delete(key string, opts ...store.DeleteOption) error { + result, err := s.deletePrepare.Exec(key) + if err != nil { + return err + } + _, err = result.RowsAffected() + if err != nil { + return err + } + + return nil +} + +func (s *sqlStore) initDB() error { + // Create the namespace's database + _, err := s.db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s ;", s.database)) + if err != nil { + return err + } + + _, err = s.db.Exec(fmt.Sprintf("USE %s ;", s.database)) + if err != nil { + return errors.Wrap(err, "Couldn't use database") + } + + // Create a table for the namespace's prefix + createSQL := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (`key` varchar(255) primary key, value blob null, expiry timestamp not null);", s.table) + _, err = s.db.Exec(createSQL) + if err != nil { + return errors.Wrap(err, "Couldn't create table") + } + + // prepare + s.readPrepare, _ = s.db.Prepare(fmt.Sprintf("SELECT `key`, value, expiry FROM %s.%s WHERE `key` = ?;", s.database, s.table)) + s.writePrepare, _ = s.db.Prepare(fmt.Sprintf("INSERT INTO %s.%s (`key`, value, expiry) VALUES(?, ?, ?) ON DUPLICATE KEY UPDATE `value`= ?, `expiry` = ?", s.database, s.table)) + s.deletePrepare, _ = s.db.Prepare(fmt.Sprintf("DELETE FROM %s.%s WHERE `key` = ?;", s.database, s.table)) + + return nil +} + +func (s *sqlStore) configure() error { + nodes := s.options.Nodes + if len(nodes) == 0 { + nodes = []string{"localhost:3306"} + } + + database := s.options.Database + if len(database) == 0 { + database = DefaultDatabase + } + + table := s.options.Table + if len(table) == 0 { + table = DefaultTable + } + + for _, r := range database { + if !unicode.IsLetter(r) { + return errors.New("store.namespace must only contain letters") + } + } + + source := nodes[0] + // create source from first node + db, err := sql.Open("mysql", source) + if err != nil { + return err + } + + if err := db.Ping(); err != nil { + return err + } + + if s.db != nil { + s.db.Close() + } + + // save the values + s.db = db + s.database = database + s.table = table + + // initialize the database + return s.initDB() +} + +func (s *sqlStore) String() string { + return "mysql" +} + +// New returns a new micro Store backed by sql. +func NewMysqlStore(opts ...store.Option) store.Store { + var options store.Options + for _, o := range opts { + o(&options) + } + + // new store + s := new(sqlStore) + // set the options + s.options = options + + // configure the store + if err := s.configure(); err != nil { + log.Fatal(err) + } + + // return store + return s +} diff --git a/store/mysql/mysql_test.go b/store/mysql/mysql_test.go new file mode 100644 index 00000000..94bbbf99 --- /dev/null +++ b/store/mysql/mysql_test.go @@ -0,0 +1,69 @@ +//go:build integration +// +build integration + +package mysql + +import ( + "encoding/json" + "os" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + "go-micro.dev/v5/store" +) + +var ( + sqlStoreT store.Store +) + +func TestMain(m *testing.M) { + if tr := os.Getenv("TRAVIS"); len(tr) > 0 { + os.Exit(0) + } + + sqlStoreT = NewMysqlStore( + store.Database("testMicro"), + store.Nodes("root:123@(127.0.0.1:3306)/test?charset=utf8&parseTime=true&loc=Asia%2FShanghai"), + ) + os.Exit(m.Run()) +} + +func TestWrite(t *testing.T) { + err := sqlStoreT.Write( + &store.Record{ + Key: "test", + Value: []byte("foo2"), + Expiry: time.Second * 200, + }, + ) + if err != nil { + t.Error(err) + } +} + +func TestDelete(t *testing.T) { + err := sqlStoreT.Delete("test") + if err != nil { + t.Error(err) + } +} + +func TestRead(t *testing.T) { + records, err := sqlStoreT.Read("test") + if err != nil { + t.Error(err) + } + + t.Log(string(records[0].Value)) +} + +func TestList(t *testing.T) { + records, err := sqlStoreT.List() + if err != nil { + t.Error(err) + } else { + beauty, _ := json.Marshal(records) + t.Log(string(beauty)) + } +} diff --git a/transport/http_transport_test.go b/transport/http_transport_test.go index 23ec10d7..446771ec 100644 --- a/transport/http_transport_test.go +++ b/transport/http_transport_test.go @@ -24,17 +24,17 @@ func expectedPort(t *testing.T, expected string, lsn Listener) { func TestHTTPTransportPortRange(t *testing.T) { tp := NewHTTPTransport() - lsn1, err := tp.Listen(":44444-44448") + lsn1, err := tp.Listen(":44445-44449") if err != nil { t.Errorf("Did not expect an error, got %s", err) } - expectedPort(t, "44444", lsn1) + expectedPort(t, "44445", lsn1) - lsn2, err := tp.Listen(":44444-44448") + lsn2, err := tp.Listen(":44445-44449") if err != nil { t.Errorf("Did not expect an error, got %s", err) } - expectedPort(t, "44445", lsn2) + expectedPort(t, "44446", lsn2) lsn, err := tp.Listen("127.0.0.1:0") if err != nil {