From dd3546eff9f087c12d498a2fc94d11ab8b4072e1 Mon Sep 17 00:00:00 2001 From: Ante Gulin Date: Thu, 11 Jun 2026 11:24:44 +0200 Subject: [PATCH] PMM-14734 Add `ExpectedNodes` field --- api/ha/v1beta1/ha.pb.go | 17 ++++-- api/ha/v1beta1/ha.pb.validate.go | 2 + api/ha/v1beta1/ha.proto | 2 + .../client/ha_service/list_nodes_responses.go | 3 ++ api/ha/v1beta1/json/v1beta1.json | 6 +++ api/swagger/swagger-dev.json | 6 +++ api/swagger/swagger.json | 6 +++ managed/services/ha/ha.go | 13 ++++- managed/services/ha/ha_test.go | 52 +++++++++++++++++++ 9 files changed, 101 insertions(+), 6 deletions(-) diff --git a/api/ha/v1beta1/ha.pb.go b/api/ha/v1beta1/ha.pb.go index f798cccbcc0..65b74716cdd 100644 --- a/api/ha/v1beta1/ha.pb.go +++ b/api/ha/v1beta1/ha.pb.go @@ -177,7 +177,9 @@ func (x *HANode) GetStatus() string { type ListNodesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // List of nodes in the HA cluster. - Nodes []*HANode `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` + Nodes []*HANode `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` + // Expected number of nodes in the cluster + ExpectedNodes int32 `protobuf:"varint,2,opt,name=expected_nodes,json=expectedNodes,proto3" json:"expected_nodes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -219,6 +221,13 @@ func (x *ListNodesResponse) GetNodes() []*HANode { return nil } +func (x *ListNodesResponse) GetExpectedNodes() int32 { + if x != nil { + return x.ExpectedNodes + } + return 0 +} + type StatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -310,9 +319,10 @@ const file_ha_v1beta1_ha_proto_rawDesc = "" + "\x06HANode\x12\x1b\n" + "\tnode_name\x18\x01 \x01(\tR\bnodeName\x12(\n" + "\x04role\x18\x02 \x01(\x0e2\x14.ha.v1beta1.NodeRoleR\x04role\x12\x16\n" + - "\x06status\x18\x03 \x01(\tR\x06status\"=\n" + + "\x06status\x18\x03 \x01(\tR\x06status\"d\n" + "\x11ListNodesResponse\x12(\n" + - "\x05nodes\x18\x01 \x03(\v2\x12.ha.v1beta1.HANodeR\x05nodes\"\x0f\n" + + "\x05nodes\x18\x01 \x03(\v2\x12.ha.v1beta1.HANodeR\x05nodes\x12%\n" + + "\x0eexpected_nodes\x18\x02 \x01(\x05R\rexpectedNodes\"\x0f\n" + "\rStatusRequest\"(\n" + "\x0eStatusResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status*S\n" + @@ -351,7 +361,6 @@ var ( (*StatusResponse)(nil), // 5: ha.v1beta1.StatusResponse } ) - var file_ha_v1beta1_ha_proto_depIdxs = []int32{ 0, // 0: ha.v1beta1.HANode.role:type_name -> ha.v1beta1.NodeRole 2, // 1: ha.v1beta1.ListNodesResponse.nodes:type_name -> ha.v1beta1.HANode diff --git a/api/ha/v1beta1/ha.pb.validate.go b/api/ha/v1beta1/ha.pb.validate.go index 36ad57e423b..bf1c6293767 100644 --- a/api/ha/v1beta1/ha.pb.validate.go +++ b/api/ha/v1beta1/ha.pb.validate.go @@ -297,6 +297,8 @@ func (m *ListNodesResponse) validate(all bool) error { } + // no validation rules for ExpectedNodes + if len(errors) > 0 { return ListNodesResponseMultiError(errors) } diff --git a/api/ha/v1beta1/ha.proto b/api/ha/v1beta1/ha.proto index 66578818d3d..ef1c88e8bd9 100644 --- a/api/ha/v1beta1/ha.proto +++ b/api/ha/v1beta1/ha.proto @@ -27,6 +27,8 @@ message HANode { message ListNodesResponse { // List of nodes in the HA cluster. repeated HANode nodes = 1; + // Expected number of nodes in the cluster + int32 expected_nodes = 2; } message StatusRequest {} diff --git a/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go b/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go index 79f40277720..8dc944843b3 100644 --- a/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go +++ b/api/ha/v1beta1/json/client/ha_service/list_nodes_responses.go @@ -420,6 +420,9 @@ swagger:model ListNodesOKBody type ListNodesOKBody struct { // List of nodes in the HA cluster. Nodes []*ListNodesOKBodyNodesItems0 `json:"nodes"` + + // Expected number of nodes in the cluster + ExpectedNodes int32 `json:"expected_nodes,omitempty"` } // Validate validates this list nodes OK body diff --git a/api/ha/v1beta1/json/v1beta1.json b/api/ha/v1beta1/json/v1beta1.json index df5b60e712e..c31b0b7b675 100644 --- a/api/ha/v1beta1/json/v1beta1.json +++ b/api/ha/v1beta1/json/v1beta1.json @@ -60,6 +60,12 @@ } }, "x-order": 0 + }, + "expected_nodes": { + "type": "integer", + "format": "int32", + "title": "Expected number of nodes in the cluster", + "x-order": 1 } } } diff --git a/api/swagger/swagger-dev.json b/api/swagger/swagger-dev.json index f521777f744..412ac3f32ab 100644 --- a/api/swagger/swagger-dev.json +++ b/api/swagger/swagger-dev.json @@ -5209,6 +5209,12 @@ } }, "x-order": 0 + }, + "expected_nodes": { + "type": "integer", + "format": "int32", + "title": "Expected number of nodes in the cluster", + "x-order": 1 } } } diff --git a/api/swagger/swagger.json b/api/swagger/swagger.json index ae34b9296a5..6174c2d69d6 100644 --- a/api/swagger/swagger.json +++ b/api/swagger/swagger.json @@ -4236,6 +4236,12 @@ } }, "x-order": 0 + }, + "expected_nodes": { + "type": "integer", + "format": "int32", + "title": "Expected number of nodes in the cluster", + "x-order": 1 } } } diff --git a/managed/services/ha/ha.go b/managed/services/ha/ha.go index c33bc9b0515..4156a8ff5a5 100644 --- a/managed/services/ha/ha.go +++ b/managed/services/ha/ha.go @@ -51,13 +51,19 @@ func (s *HAServer) ListNodes(_ context.Context, _ *hav1beta1.ListNodesRequest) ( return &hav1beta1.ListNodesResponse{Nodes: []*hav1beta1.HANode{}}, nil } + // Default to 1 for single-node deployment where no peers are configured. + expectedNodes := max(len(s.service.params.Nodes), 1) + s.service.rw.RLock() memberlist := s.service.memberlist raftNode := s.service.raftNode s.service.rw.RUnlock() if memberlist == nil { - return &hav1beta1.ListNodesResponse{Nodes: []*hav1beta1.HANode{}}, nil + return &hav1beta1.ListNodesResponse{ + Nodes: []*hav1beta1.HANode{}, + ExpectedNodes: int32(expectedNodes), + }, nil } _, leaderID := raftNode.LeaderWithID() @@ -79,7 +85,10 @@ func (s *HAServer) ListNodes(_ context.Context, _ *hav1beta1.ListNodesRequest) ( }) } - return &hav1beta1.ListNodesResponse{Nodes: nodes}, nil + return &hav1beta1.ListNodesResponse{ + Nodes: nodes, + ExpectedNodes: int32(expectedNodes), + }, nil } // memberlistStateToString converts memberlist state to a string representation. diff --git a/managed/services/ha/ha_test.go b/managed/services/ha/ha_test.go index c7f0901a1ed..4f2a3ede29d 100644 --- a/managed/services/ha/ha_test.go +++ b/managed/services/ha/ha_test.go @@ -84,6 +84,7 @@ func TestHAServer_ListNodes_HADisabled(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) assert.Empty(t, resp.Nodes) + assert.Equal(t, int32(0), resp.ExpectedNodes) } func TestHAServer_ListNodes_NilMemberlist(t *testing.T) { @@ -104,6 +105,57 @@ func TestHAServer_ListNodes_NilMemberlist(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) assert.Empty(t, resp.Nodes) + assert.Equal(t, int32(1), resp.ExpectedNodes) +} + +func TestHAServer_ListNodes_ExpectedNodes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + nodes []string + expectedNodes int32 + }{ + { + name: "no peers configured defaults to 1", + nodes: nil, + expectedNodes: 1, + }, + { + name: "single peer configured", + nodes: []string{"node-1"}, + expectedNodes: 1, + }, + { + name: "multiple peers configured", + nodes: []string{"node-1", "node-2", "node-3"}, + expectedNodes: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + service := &Service{ + params: &models.HAParams{ + Enabled: true, + Nodes: tt.nodes, + }, + memberlist: nil, + rw: sync.RWMutex{}, + } + + server := NewHAServer(service) + + resp, err := server.ListNodes(t.Context(), &hav1beta1.ListNodesRequest{}) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.Nodes) + assert.Equal(t, tt.expectedNodes, resp.ExpectedNodes) + }) + } } func TestMemberlistStateToString(t *testing.T) {