From ff4bab00107f880cb514947c19cf215dc554e1b3 Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Mon, 22 Apr 2024 18:21:37 -0500 Subject: [PATCH 1/7] feat: Redis plug-in node discovery --- plugins/inputs/redis/redis.go | 146 +++++++++++++++++++++++++---- plugins/inputs/redis/redis_test.go | 81 ++++++++++++++++ 2 files changed, 211 insertions(+), 16 deletions(-) diff --git a/plugins/inputs/redis/redis.go b/plugins/inputs/redis/redis.go index b19cdd8a611a3..78540e084ec65 100644 --- a/plugins/inputs/redis/redis.go +++ b/plugins/inputs/redis/redis.go @@ -32,10 +32,11 @@ type RedisCommand struct { } type Redis struct { - Commands []*RedisCommand `toml:"commands"` - Servers []string `toml:"servers"` - Username string `toml:"username"` - Password string `toml:"password"` + Commands []*RedisCommand `toml:"commands"` + Servers []string `toml:"servers"` + Username string `toml:"username"` + Password string `toml:"password"` + NodeDiscovery bool `toml:"node_discovery"` tls.ClientConfig @@ -57,6 +58,12 @@ type RedisClient struct { tags map[string]string } +type redisClusterNode struct { + nodeId string + host string + port string +} + // RedisFieldTypes defines the types expected for each of the fields redis reports on type RedisFieldTypes struct { ActiveDefragHits int64 `json:"active_defrag_hits"` @@ -222,7 +229,7 @@ func (r *Redis) Init() error { } func (r *Redis) connect() error { - if r.connected { + if r.connected && !r.NodeDiscovery { return nil } @@ -281,18 +288,61 @@ func (r *Redis) connect() error { }, ) - tags := map[string]string{} - if u.Scheme == "unix" { - tags["socket"] = u.Path + if r.NodeDiscovery { + nodes, err := discoverNodes(&RedisClient{client, make(map[string]string)}) + if err != nil { + return err + } + if nodes == nil { + return fmt.Errorf("unable to discover nodes from %s", address) + } + for _, node := range nodes { + + nodeAddress := "" + if node.host == "" { + nodeAddress = address + } else { + nodeAddress = fmt.Sprintf("%s:%s", node.host, node.port) + } + + discoveredClient := redis.NewClient( + &redis.Options{ + Addr: nodeAddress, + Username: username, + Password: password, + Network: u.Scheme, + PoolSize: 1, + TLSConfig: tlsConfig, + }, + ) + + tags := map[string]string{ + "server": u.Hostname(), + "port": node.port, + "nodeId": node.nodeId, + // "replication_role": node.replicationRole, + } + + r.clients = append(r.clients, &RedisClient{ + client: discoveredClient, + tags: tags, + }) + } + } else { - tags["server"] = u.Hostname() - tags["port"] = u.Port() - } + tags := map[string]string{} + if u.Scheme == "unix" { + tags["socket"] = u.Path + } else { + tags["server"] = u.Hostname() + tags["port"] = u.Port() + } - r.clients = append(r.clients, &RedisClient{ - client: client, - tags: tags, - }) + r.clients = append(r.clients, &RedisClient{ + client: client, + tags: tags, + }) + } } r.connected = true @@ -302,7 +352,7 @@ func (r *Redis) connect() error { // Reads stats from all configured servers accumulates stats. // Returns one of the errors encountered while gather stats (if any). func (r *Redis) Gather(acc telegraf.Accumulator) error { - if !r.connected { + if !r.connected || r.NodeDiscovery { err := r.connect() if err != nil { return err @@ -344,6 +394,26 @@ func (r *Redis) gatherCommandValues(client Client, acc telegraf.Accumulator) err return nil } +func discoverNodes(client Client) ([]redisClusterNode, error) { + val, err := client.Do("string", "cluster", "nodes") + + if err != nil { + if strings.Contains(err.Error(), "unexpected type=") { + return nil, fmt.Errorf("could not get command result: %w", err) + } + + return nil, err + } + + str, ok := val.(string) + if ok { + return parseClusterNodes(str) + } else { + return nil, fmt.Errorf("could not discover nodes: %w", err) + } + +} + func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { info, err := client.Info().Result() if err != nil { @@ -354,6 +424,50 @@ func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { return gatherInfoOutput(rdr, acc, client.BaseTags()) } +// Parse the list of nodes from a `cluster nodes` command +// This response looks like: +// +// d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364 +// 3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729 +// d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095 +// +// Per Redis docs: +// (https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/) +// +// In the above listing the different fields are in order: +// node id, address:port, flags, last ping sent, last pong received, +// configuration epoch, link state, slots. +func parseClusterNodes(nodesResponse string) ([]redisClusterNode, error) { + + lines := strings.Split(nodesResponse, "\n") + var nodes []redisClusterNode + + for _, line := range lines { + fields := strings.Fields(line) + + if len(fields) >= 8 { + endpointParts := strings.FieldsFunc(fields[1], func(r rune) bool { + return strings.ContainsRune(":@", r) + }) + if string(fields[1][0]) == ":" { + endpointParts = append([]string{""}, endpointParts...) + } + + nodes = append(nodes, redisClusterNode{ + nodeId: fields[0], + host: endpointParts[0], + port: endpointParts[1], + }) + } else if len(fields) == 0 { + continue + } else { + return nil, fmt.Errorf("unexpected cluster node: \"%s\"", line) + } + } + + return nodes, nil +} + // gatherInfoOutput gathers func gatherInfoOutput( rdr io.Reader, diff --git a/plugins/inputs/redis/redis_test.go b/plugins/inputs/redis/redis_test.go index e5e588fd76e23..4b8855113bed7 100644 --- a/plugins/inputs/redis/redis_test.go +++ b/plugins/inputs/redis/redis_test.go @@ -3,6 +3,7 @@ package redis import ( "bufio" "fmt" + "reflect" "strings" "testing" "time" @@ -62,6 +63,86 @@ func TestRedisConnectIntegration(t *testing.T) { require.NoError(t, err) } +func TestRedisConnectWithNodeDiscoveryIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + servicePort := "6379" + container := testutil.Container{ + Image: "redis:alpine", + ExposedPorts: []string{servicePort}, + WaitingFor: wait.ForListeningPort(nat.Port(servicePort)), + Cmd: []string{ + "redis-server", + "--port", servicePort, + "--appendonly", "yes", + "--cluster-enabled", "yes", + "--cluster-config-file", "nodes.conf", + "--cluster-node-timeout", "5000", + }, + } + err := container.Start() + require.NoError(t, err, "failed to start container") + defer container.Terminate() + + addr := fmt.Sprintf("tcp://%s:%s", container.Address, container.Ports[servicePort]) + + r := &Redis{ + Log: testutil.Logger{}, + Servers: []string{addr}, + NodeDiscovery: true, + } + + var acc testutil.Accumulator + + err = acc.GatherError(r.Gather) + require.NoError(t, err) +} + +func TestParseClusterNodes(t *testing.T) { + + var tests = map[string]struct { + clusterNodesResponse string + expectedNodes []redisClusterNode + }{ + "SingleContainerCluster": { + "5998443a50112d5a7fa619c0b044451df052974e :6379@16379 myself,master - 0 0 0 connected\n", + []redisClusterNode{ + {nodeId: "5998443a50112d5a7fa619c0b044451df052974e", host: "", port: "6379"}, + }, + }, + "ClusterNodesResponseA": { + `d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364 + 3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729 + d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095`, + []redisClusterNode{ + {nodeId: "d1861060fe6a534d42d8a19aeb36600e18785e04", host: "127.0.0.1", port: "6379"}, + {nodeId: "3886e65cc906bfd9b1f7e7bde468726a052d1dae", host: "127.0.0.1", port: "6380"}, + {nodeId: "d289c575dcbc4bdd2931585fd4339089e461a27d", host: "127.0.0.1", port: "6381"}, + }, + }, + "ClusterNodesResponseB": { + `4ce37a099986f2d0465955e2e66937d6893aa0e1 10.64.82.45:11006@16379 myself,master - 0 1713739012000 5 connected 5462-10922 + d6eb119a1f050982cc901ae663e7448867e49f7c 10.64.82.46:11005@16379 master - 0 1713739011916 4 connected 10923-16383 + 3a386fb6930d8f6c1a6536082071eb2f32590d31 10.64.82.46:11007@16379 master - 0 1713739012922 6 connected 0-5461`, + []redisClusterNode{ + {nodeId: "4ce37a099986f2d0465955e2e66937d6893aa0e1", host: "10.64.82.45", port: "11006"}, + {nodeId: "d6eb119a1f050982cc901ae663e7448867e49f7c", host: "10.64.82.46", port: "11005"}, + {nodeId: "3a386fb6930d8f6c1a6536082071eb2f32590d31", host: "10.64.82.46", port: "11007"}, + }, + }, + } + + for tname, tt := range tests { + testResponse, _ := parseClusterNodes(tt.clusterNodesResponse) + if !reflect.DeepEqual(testResponse, tt.expectedNodes) { + // t.Errorf("%s fail!", tname) + t.Error(tname, "fail! Got:\n", testResponse, "\nExpected:\n", tt.expectedNodes) + } + } +} + func TestRedis_Commands(t *testing.T) { const redisListKey = "test-list-length" var acc testutil.Accumulator From efac4c7a23ac2b8735df1637c8226b6cf055bd3b Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Mon, 22 Apr 2024 18:30:53 -0500 Subject: [PATCH 2/7] docs: Redis plug-in node discovery --- plugins/inputs/redis/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/inputs/redis/README.md b/plugins/inputs/redis/README.md index 35e8b3528e07e..bc8925375ebe5 100644 --- a/plugins/inputs/redis/README.md +++ b/plugins/inputs/redis/README.md @@ -43,6 +43,13 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. # username = "" # password = "" + ## Optional Node Discovery + ## Enabling has the Telegraf agent connects to the define server + ## and issue a `cluster nodes` command which lists nodes on a Redis Cluster. + ## Telegraf then pulls metrics from all discovered nodes. + ## It assumes consistent tls/auth configuration for each. + # node_discovery = true + ## Optional TLS Config ## Check tls/config.go ClientConfig for more options # tls_enable = true From 75b2d57e669f1659a5091f1100e6e740449c59c9 Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Mon, 22 Apr 2024 18:36:25 -0500 Subject: [PATCH 3/7] docs: Redis plug-in node discovery --- plugins/inputs/redis/sample.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/inputs/redis/sample.conf b/plugins/inputs/redis/sample.conf index 00571cbd7cb37..4d9c2f7f7d8bb 100644 --- a/plugins/inputs/redis/sample.conf +++ b/plugins/inputs/redis/sample.conf @@ -27,6 +27,13 @@ # username = "" # password = "" + ## Optional Node Discovery + ## Enabling has the Telegraf agent connects to the define server + ## and issue a `cluster nodes` command which lists nodes on a Redis Cluster. + ## Telegraf then pulls metrics from all discovered nodes. + ## It assumes consistent tls/auth configuration for each. + # node_discovery = true + ## Optional TLS Config ## Check tls/config.go ClientConfig for more options # tls_enable = true From 479b7e790ea55a00689fa2722438841bea27e0e2 Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Mon, 22 Apr 2024 19:20:58 -0500 Subject: [PATCH 4/7] delint --- plugins/inputs/redis/redis.go | 14 ++++---------- plugins/inputs/redis/redis_test.go | 16 +++++++--------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/plugins/inputs/redis/redis.go b/plugins/inputs/redis/redis.go index 78540e084ec65..2e8587ee41b17 100644 --- a/plugins/inputs/redis/redis.go +++ b/plugins/inputs/redis/redis.go @@ -59,7 +59,7 @@ type RedisClient struct { } type redisClusterNode struct { - nodeId string + nodeID string host string port string } @@ -297,7 +297,6 @@ func (r *Redis) connect() error { return fmt.Errorf("unable to discover nodes from %s", address) } for _, node := range nodes { - nodeAddress := "" if node.host == "" { nodeAddress = address @@ -319,8 +318,7 @@ func (r *Redis) connect() error { tags := map[string]string{ "server": u.Hostname(), "port": node.port, - "nodeId": node.nodeId, - // "replication_role": node.replicationRole, + "nodeID": node.nodeID, } r.clients = append(r.clients, &RedisClient{ @@ -328,7 +326,6 @@ func (r *Redis) connect() error { tags: tags, }) } - } else { tags := map[string]string{} if u.Scheme == "unix" { @@ -408,10 +405,8 @@ func discoverNodes(client Client) ([]redisClusterNode, error) { str, ok := val.(string) if ok { return parseClusterNodes(str) - } else { - return nil, fmt.Errorf("could not discover nodes: %w", err) } - + return nil, fmt.Errorf("could not discover nodes: %w", err) } func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { @@ -438,7 +433,6 @@ func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { // node id, address:port, flags, last ping sent, last pong received, // configuration epoch, link state, slots. func parseClusterNodes(nodesResponse string) ([]redisClusterNode, error) { - lines := strings.Split(nodesResponse, "\n") var nodes []redisClusterNode @@ -454,7 +448,7 @@ func parseClusterNodes(nodesResponse string) ([]redisClusterNode, error) { } nodes = append(nodes, redisClusterNode{ - nodeId: fields[0], + nodeID: fields[0], host: endpointParts[0], port: endpointParts[1], }) diff --git a/plugins/inputs/redis/redis_test.go b/plugins/inputs/redis/redis_test.go index 4b8855113bed7..01eafd3554dad 100644 --- a/plugins/inputs/redis/redis_test.go +++ b/plugins/inputs/redis/redis_test.go @@ -101,7 +101,6 @@ func TestRedisConnectWithNodeDiscoveryIntegration(t *testing.T) { } func TestParseClusterNodes(t *testing.T) { - var tests = map[string]struct { clusterNodesResponse string expectedNodes []redisClusterNode @@ -109,7 +108,7 @@ func TestParseClusterNodes(t *testing.T) { "SingleContainerCluster": { "5998443a50112d5a7fa619c0b044451df052974e :6379@16379 myself,master - 0 0 0 connected\n", []redisClusterNode{ - {nodeId: "5998443a50112d5a7fa619c0b044451df052974e", host: "", port: "6379"}, + {nodeID: "5998443a50112d5a7fa619c0b044451df052974e", host: "", port: "6379"}, }, }, "ClusterNodesResponseA": { @@ -117,9 +116,9 @@ func TestParseClusterNodes(t *testing.T) { 3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729 d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095`, []redisClusterNode{ - {nodeId: "d1861060fe6a534d42d8a19aeb36600e18785e04", host: "127.0.0.1", port: "6379"}, - {nodeId: "3886e65cc906bfd9b1f7e7bde468726a052d1dae", host: "127.0.0.1", port: "6380"}, - {nodeId: "d289c575dcbc4bdd2931585fd4339089e461a27d", host: "127.0.0.1", port: "6381"}, + {nodeID: "d1861060fe6a534d42d8a19aeb36600e18785e04", host: "127.0.0.1", port: "6379"}, + {nodeID: "3886e65cc906bfd9b1f7e7bde468726a052d1dae", host: "127.0.0.1", port: "6380"}, + {nodeID: "d289c575dcbc4bdd2931585fd4339089e461a27d", host: "127.0.0.1", port: "6381"}, }, }, "ClusterNodesResponseB": { @@ -127,9 +126,9 @@ func TestParseClusterNodes(t *testing.T) { d6eb119a1f050982cc901ae663e7448867e49f7c 10.64.82.46:11005@16379 master - 0 1713739011916 4 connected 10923-16383 3a386fb6930d8f6c1a6536082071eb2f32590d31 10.64.82.46:11007@16379 master - 0 1713739012922 6 connected 0-5461`, []redisClusterNode{ - {nodeId: "4ce37a099986f2d0465955e2e66937d6893aa0e1", host: "10.64.82.45", port: "11006"}, - {nodeId: "d6eb119a1f050982cc901ae663e7448867e49f7c", host: "10.64.82.46", port: "11005"}, - {nodeId: "3a386fb6930d8f6c1a6536082071eb2f32590d31", host: "10.64.82.46", port: "11007"}, + {nodeID: "4ce37a099986f2d0465955e2e66937d6893aa0e1", host: "10.64.82.45", port: "11006"}, + {nodeID: "d6eb119a1f050982cc901ae663e7448867e49f7c", host: "10.64.82.46", port: "11005"}, + {nodeID: "3a386fb6930d8f6c1a6536082071eb2f32590d31", host: "10.64.82.46", port: "11007"}, }, }, } @@ -137,7 +136,6 @@ func TestParseClusterNodes(t *testing.T) { for tname, tt := range tests { testResponse, _ := parseClusterNodes(tt.clusterNodesResponse) if !reflect.DeepEqual(testResponse, tt.expectedNodes) { - // t.Errorf("%s fail!", tname) t.Error(tname, "fail! Got:\n", testResponse, "\nExpected:\n", tt.expectedNodes) } } From 61509bfa2b1e17cdfca1363183ebcec12004a6d7 Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Mon, 22 Apr 2024 21:44:31 -0500 Subject: [PATCH 5/7] Clarify docs --- plugins/inputs/redis/README.md | 9 ++++----- plugins/inputs/redis/sample.conf | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/plugins/inputs/redis/README.md b/plugins/inputs/redis/README.md index bc8925375ebe5..797ceb16750a9 100644 --- a/plugins/inputs/redis/README.md +++ b/plugins/inputs/redis/README.md @@ -43,11 +43,10 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details. # username = "" # password = "" - ## Optional Node Discovery - ## Enabling has the Telegraf agent connects to the define server - ## and issue a `cluster nodes` command which lists nodes on a Redis Cluster. - ## Telegraf then pulls metrics from all discovered nodes. - ## It assumes consistent tls/auth configuration for each. + ## Optional Node Discovery for Redis Cluster + ## Enabling this feature triggers the execution of a `cluster nodes` command + ## at each metric gathering interval. This command automatically detects + ## all cluster nodes and retrieves metrics from each of them. # node_discovery = true ## Optional TLS Config diff --git a/plugins/inputs/redis/sample.conf b/plugins/inputs/redis/sample.conf index 4d9c2f7f7d8bb..4767a091d5c98 100644 --- a/plugins/inputs/redis/sample.conf +++ b/plugins/inputs/redis/sample.conf @@ -27,11 +27,10 @@ # username = "" # password = "" - ## Optional Node Discovery - ## Enabling has the Telegraf agent connects to the define server - ## and issue a `cluster nodes` command which lists nodes on a Redis Cluster. - ## Telegraf then pulls metrics from all discovered nodes. - ## It assumes consistent tls/auth configuration for each. + ## Optional Node Discovery for Redis Cluster + ## Enabling this feature triggers the execution of a `cluster nodes` command + ## at each metric gathering interval. This command automatically detects + ## all cluster nodes and retrieves metrics from each of them. # node_discovery = true ## Optional TLS Config From 2b045c293bf1bea853cfebe82f8dfd592b3e1e2b Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Tue, 23 Apr 2024 10:52:40 -0500 Subject: [PATCH 6/7] Address Review Style Suggestions --- plugins/inputs/redis/redis.go | 48 +++++++++++++++-------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/plugins/inputs/redis/redis.go b/plugins/inputs/redis/redis.go index 2e8587ee41b17..5df8a7adb62c6 100644 --- a/plugins/inputs/redis/redis.go +++ b/plugins/inputs/redis/redis.go @@ -297,11 +297,9 @@ func (r *Redis) connect() error { return fmt.Errorf("unable to discover nodes from %s", address) } for _, node := range nodes { - nodeAddress := "" - if node.host == "" { - nodeAddress = address - } else { - nodeAddress = fmt.Sprintf("%s:%s", node.host, node.port) + nodeAddress := address + if node.host != "" { + nodeAddress = node.host + ":" + node.port } discoveredClient := redis.NewClient( @@ -395,18 +393,14 @@ func discoverNodes(client Client) ([]redisClusterNode, error) { val, err := client.Do("string", "cluster", "nodes") if err != nil { - if strings.Contains(err.Error(), "unexpected type=") { - return nil, fmt.Errorf("could not get command result: %w", err) - } - return nil, err } str, ok := val.(string) - if ok { - return parseClusterNodes(str) + if !ok { + return nil, fmt.Errorf("could not discover nodes: %w", err) } - return nil, fmt.Errorf("could not discover nodes: %w", err) + return parseClusterNodes(str) } func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { @@ -439,24 +433,24 @@ func parseClusterNodes(nodesResponse string) ([]redisClusterNode, error) { for _, line := range lines { fields := strings.Fields(line) - if len(fields) >= 8 { - endpointParts := strings.FieldsFunc(fields[1], func(r rune) bool { - return strings.ContainsRune(":@", r) - }) - if string(fields[1][0]) == ":" { - endpointParts = append([]string{""}, endpointParts...) - } - - nodes = append(nodes, redisClusterNode{ - nodeID: fields[0], - host: endpointParts[0], - port: endpointParts[1], - }) - } else if len(fields) == 0 { + if len(fields) == 0 { continue - } else { + } + if len(fields) < 8 { return nil, fmt.Errorf("unexpected cluster node: \"%s\"", line) } + + endpointParts := strings.FieldsFunc(fields[1], func(r rune) bool { + return strings.ContainsRune(":@", r) + }) + if string(fields[1][0]) == ":" { + endpointParts = append([]string{""}, endpointParts...) + } + nodes = append(nodes, redisClusterNode{ + nodeID: fields[0], + host: endpointParts[0], + port: endpointParts[1], + }) } return nodes, nil From 0a424c675b2bddd54c11c0b8bd9b571d6b7020c1 Mon Sep 17 00:00:00 2001 From: Jeremy Campbell Date: Tue, 23 Apr 2024 17:17:08 -0500 Subject: [PATCH 7/7] delint --- plugins/inputs/redis/redis.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/inputs/redis/redis.go b/plugins/inputs/redis/redis.go index 5df8a7adb62c6..b2a5b4467a087 100644 --- a/plugins/inputs/redis/redis.go +++ b/plugins/inputs/redis/redis.go @@ -428,7 +428,7 @@ func (r *Redis) gatherServer(client Client, acc telegraf.Accumulator) error { // configuration epoch, link state, slots. func parseClusterNodes(nodesResponse string) ([]redisClusterNode, error) { lines := strings.Split(nodesResponse, "\n") - var nodes []redisClusterNode + nodes := make([]redisClusterNode, 0, len(lines)) for _, line := range lines { fields := strings.Fields(line)