github.com/m3db/m3@v1.5.1-0.20231129193456-75a402aa583b/src/integration/resources/docker/dockerexternal/etcdintegration/cluster.go (about)

     1  // Copyright (c) 2022 Uber Technologies, Inc.
     2  //
     3  // Permission is hereby granted, free of charge, to any person obtaining a copy
     4  // of this software and associated documentation files (the "Software"), to deal
     5  // in the Software without restriction, including without limitation the rights
     6  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     7  // copies of the Software, and to permit persons to whom the Software is
     8  // furnished to do so, subject to the following conditions:
     9  //
    10  // The above copyright notice and this permission notice shall be included in
    11  // all copies or substantial portions of the Software.
    12  //
    13  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    14  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    15  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    16  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    17  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    18  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    19  // THE SOFTWARE.
    20  
    21  // Package etcdintegration is a mostly drop-in replacement for the etcd integration
    22  // (github.com/etcd-io/etcd/tests/v3/framework/integration) package.
    23  // Instead of starting etcd within this Go process, it starts etcd using a docker container.
    24  package etcdintegration
    25  
    26  import (
    27  	"context"
    28  	"fmt"
    29  	"math/rand"
    30  	"net"
    31  	"time"
    32  
    33  	"github.com/m3db/m3/src/integration/resources/docker/dockerexternal"
    34  	"github.com/m3db/m3/src/integration/resources/docker/dockerexternal/etcdintegration/bridge"
    35  	xerrors "github.com/m3db/m3/src/x/errors"
    36  	"github.com/m3db/m3/src/x/instrument"
    37  	"github.com/m3db/m3/src/x/retry"
    38  
    39  	"github.com/ory/dockertest/v3"
    40  	"github.com/stretchr/testify/require"
    41  	clientv3 "go.etcd.io/etcd/client/v3"
    42  	"go.uber.org/zap"
    43  	"go.uber.org/zap/zaptest"
    44  	"google.golang.org/grpc"
    45  )
    46  
    47  const (
    48  	startTimeout = 30 * time.Second
    49  	stopTimeout  = 30 * time.Second
    50  
    51  	clientHealthTimeout = 30 * time.Second
    52  )
    53  
    54  // ClusterConfig configures an etcd integration test cluster.
    55  type ClusterConfig struct {
    56  	// Size is the number of nodes in the cluster. Provided as a parameter to be API compatible with the etcd package,
    57  	// but currently only one node is supported.
    58  	Size int
    59  
    60  	// UseBridge enables a networking bridge on etcd members, accessible via Node.Bridge(). This allows manipulation
    61  	// of connections to particular members.
    62  	UseBridge bool
    63  }
    64  
    65  // Cluster is an etcd cluster. Currently, the implementation is such that only one node clusters are allowed.
    66  type Cluster struct {
    67  	// Members are the etcd nodes that make up the cluster.
    68  	Members []*Node
    69  
    70  	terminated bool
    71  }
    72  
    73  // NewCluster starts an etcd cluster using docker.
    74  func NewCluster(t testingT, cfg *ClusterConfig) *Cluster {
    75  	if cfg.Size > 1 {
    76  		t.Errorf("NewCluster currently only supports single node clusters")
    77  		t.FailNow()
    78  		return nil
    79  	}
    80  
    81  	logger := zaptest.NewLogger(t)
    82  
    83  	pool, err := dockertest.NewPool("")
    84  	require.NoError(t, err)
    85  
    86  	r, err := dockerexternal.NewEtcd(pool, instrument.NewOptions(), dockerexternal.EtcdClusterUseBridge(cfg.UseBridge))
    87  	require.NoError(t, err)
    88  
    89  	ctx, cancel := context.WithTimeout(context.Background(), startTimeout)
    90  	defer cancel()
    91  
    92  	cluster := &Cluster{
    93  		Members: []*Node{newNode(r, logger, cfg)},
    94  	}
    95  
    96  	require.NoError(t, cluster.start(ctx))
    97  
    98  	// Paranoia: try to ensure that we cleanup the containers, even if our callers mess up.
    99  	t.Cleanup(func() {
   100  		if !cluster.terminated {
   101  			cluster.Terminate(t)
   102  		}
   103  	})
   104  	return cluster
   105  }
   106  
   107  // start is private because NewCluster is intended to always start the cluster.
   108  func (c *Cluster) start(ctx context.Context) error {
   109  	var merr xerrors.MultiError
   110  	for _, m := range c.Members {
   111  		merr = merr.Add(m.start(ctx))
   112  	}
   113  	if err := merr.FinalError(); err != nil {
   114  		return fmt.Errorf("failed starting etcd cluster: %w", err)
   115  	}
   116  	return nil
   117  }
   118  
   119  // RandClient returns a client from any member in the cluster.
   120  func (c *Cluster) RandClient() *clientv3.Client {
   121  	//nolint:gosec
   122  	return c.Members[rand.Intn(len(c.Members))].Client
   123  }
   124  
   125  // Terminate stops all nodes in the cluster.
   126  func (c *Cluster) Terminate(t testingT) {
   127  	ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
   128  	defer cancel()
   129  
   130  	c.terminated = true
   131  
   132  	var err xerrors.MultiError
   133  	for _, node := range c.Members {
   134  		err = err.Add(node.close(ctx))
   135  	}
   136  	require.NoError(t, err.FinalError())
   137  }
   138  
   139  // Node is a single etcd server process, running in a docker container.
   140  type Node struct {
   141  	Client *clientv3.Client
   142  
   143  	resource dockerEtcd
   144  	cfg      *ClusterConfig
   145  	logger   *zap.Logger
   146  	bridge   *bridge.Bridge
   147  }
   148  
   149  func newNode(r dockerEtcd, logger *zap.Logger, cfg *ClusterConfig) *Node {
   150  	return &Node{
   151  		resource: r,
   152  		logger:   logger,
   153  		cfg:      cfg,
   154  	}
   155  }
   156  
   157  // Stop stops the etcd container, but doesn't remove it.
   158  func (n *Node) Stop(t testingT) {
   159  	ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
   160  	defer cancel()
   161  	require.NoError(t, n.resource.Stop(ctx))
   162  
   163  	if n.bridge != nil {
   164  		n.bridge.Close()
   165  	}
   166  }
   167  
   168  // Bridge can be used to manipulate connections to this etcd node. It
   169  // is a man-in-the-middle listener which mostly transparently forwards connections, unless told to drop them via e.g.
   170  // the Blackhole method.
   171  // Bridge will only be active if cfg.UseBridge is true; calling this method otherwise will panic.
   172  func (n *Node) Bridge() *bridge.Bridge {
   173  	if !n.cfg.UseBridge {
   174  		panic("EtcdNode wasn't configured to use a Bridge; pass EtcdClusterUseBridge(true) to enable.")
   175  	}
   176  	return n.bridge
   177  }
   178  
   179  // Restart starts a stopped etcd container, stopping it first if it's not already.
   180  func (n *Node) Restart(t testingT) error {
   181  	ctx, cancel := context.WithTimeout(context.Background(), startTimeout)
   182  	defer cancel()
   183  	require.NoError(t, n.resource.Restart(ctx))
   184  	return nil
   185  }
   186  
   187  // start starts the etcd node. It is private because it isn't part of the etcd/integration package API, and
   188  // should only be called by Cluster.start.
   189  func (n *Node) start(ctx context.Context) error {
   190  	ctx, cancel := context.WithTimeout(ctx, startTimeout)
   191  	defer cancel()
   192  
   193  	if err := n.resource.Setup(ctx); err != nil {
   194  		return fmt.Errorf("starting etcd container: %w", err)
   195  	}
   196  
   197  	address := n.resource.Address()
   198  	if n.cfg.UseBridge {
   199  		addr, err := n.setupBridge()
   200  		if err != nil {
   201  			return fmt.Errorf("setting up connection bridge for etcd node: %w", err)
   202  		}
   203  		address = addr
   204  	}
   205  
   206  	etcdCli, err := clientv3.New(clientv3.Config{
   207  		Endpoints:   []string{"http://" + address},
   208  		DialOptions: []grpc.DialOption{grpc.WithBlock()},
   209  		DialTimeout: 5 * time.Second,
   210  		Logger:      n.logger,
   211  	})
   212  
   213  	if err != nil {
   214  		return fmt.Errorf("constructing etcd client for member: %w", err)
   215  	}
   216  
   217  	n.logger.Info("Connecting to docker etcd using host machine port",
   218  		zap.String("endpoint", address),
   219  	)
   220  
   221  	n.Client = etcdCli
   222  	return nil
   223  }
   224  
   225  // setupBridge puts a man-in-the-middle listener in between the etcd docker process and the client. See Bridge() for
   226  // details.
   227  // Returns the new address of the bridge, which clients should connect to.
   228  func (n *Node) setupBridge() (string, error) {
   229  	listener, err := net.Listen("tcp", "127.0.0.1:0")
   230  	if err != nil {
   231  		return "", fmt.Errorf("setting up listener for bridge: %w", err)
   232  	}
   233  
   234  	n.logger.Info("etcd bridge is listening", zap.String("addr", listener.Addr().String()))
   235  
   236  	// dialer = make connections to the etcd container
   237  	// listener = the bridge's inbounds
   238  	n.bridge, err = bridge.New(dialer{hostport: n.resource.Address()}, listener)
   239  	if err != nil {
   240  		return "", err
   241  	}
   242  
   243  	return listener.Addr().String(), nil
   244  }
   245  
   246  func (n *Node) close(ctx context.Context) error {
   247  	var err xerrors.MultiError
   248  	err = err.Add(n.Client.Close())
   249  	return err.Add(n.resource.Close(ctx)).FinalError()
   250  }
   251  
   252  type dialer struct {
   253  	hostport string
   254  }
   255  
   256  func (d dialer) Dial() (net.Conn, error) {
   257  	return net.Dial("tcp", d.hostport)
   258  }
   259  
   260  // testingT wraps *testing.T. Allows us to not directly depend on *testing package.
   261  type testingT interface {
   262  	zaptest.TestingT
   263  	require.TestingT
   264  
   265  	Cleanup(func())
   266  }
   267  
   268  // BeforeTestExternal -- solely here to match etcd API's.
   269  func BeforeTestExternal(t testingT) {}
   270  
   271  // WaitClientV3 waits for an etcd client to be healthy.
   272  func WaitClientV3(t testingT, kv clientv3.KV) {
   273  	ctx, cancel := context.WithTimeout(context.Background(), clientHealthTimeout)
   274  	defer cancel()
   275  
   276  	err := retry.NewRetrier(retry.NewOptions().SetForever(true)).AttemptContext(
   277  		ctx,
   278  		func() error {
   279  			ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
   280  			defer cancel()
   281  			_, err := kv.Get(ctx, "/")
   282  			return err
   283  		},
   284  	)
   285  
   286  	require.NoError(t, err)
   287  }