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 }