Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0c3d3e9
PMM-15227 Remove scaled-down HA replicas from Inventory
4nte Aug 6, 2026
15aa2c5
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
b82c657
PMM-15227 Let the HA cleanup remove stale PMM Server Nodes
4nte Aug 6, 2026
0c04e87
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
1a1a3c5
PMM-15227 Log HA cleanup with structured fields
4nte Aug 6, 2026
d52d51a
PMM-15227 Correct the IsPMMServerNode comment
4nte Aug 6, 2026
c62e178
PMM-15227 Reject unbracketed IPv6 HA peers
4nte Aug 6, 2026
b2cbb72
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 6, 2026
eb6abb1
PMM-15227 Improve wording
4nte Aug 7, 2026
433b524
PMM-15227 Keep the pre-HA PMM Server Node
4nte Aug 10, 2026
ac9e496
PMM-15227 Pin PMM Server Node guards to a const
4nte Aug 10, 2026
dafc590
PMM-15227 Never let HA node cleanup block startup
4nte Aug 10, 2026
82eefa4
PMM-15227 Tighten the HA node cleanup
4nte Aug 10, 2026
a31b548
PMM-15227 Fail fast in the HA node test helper
4nte Aug 10, 2026
4b09b46
PMM-15227 Cover the Node ID ban under a lifted flag
4nte Aug 10, 2026
56a34df
PMM-15227 Widen the stale HA Node safety check
4nte Aug 11, 2026
dcaf91e
PMM-15227 Skip blank PMM_HA_PEERS entries
4nte Aug 11, 2026
18a6c9b
PMM-15227 Select only PMM Server nodes instead of all
4nte Aug 11, 2026
4168236
PMM-15227 Document when a stale Node is kept
4nte Aug 11, 2026
326f45e
PMM-15227 Fix typo
4nte Aug 11, 2026
b7b543e
PMM-15227 Improve docs for stale node removal
4nte Aug 11, 2026
25e9b85
PMM-15227 Add test for scale-to-one-replica sweep scenario
4nte Aug 11, 2026
79324be
PMM-15227 Bind the HA node cleanup to the startup ctx
4nte Aug 11, 2026
940d5d6
PMM-15227 Fix the advice for a Node we keep
4nte Aug 11, 2026
a8e4a51
PMM-15227 Close three gaps in the HA cleanup tests
4nte Aug 11, 2026
0a3f7b4
PMM-15227 Cover the HA peer parser directly
4nte Aug 11, 2026
799bfdb
Merge remote-tracking branch 'origin/main' into PMM-15227-remove-stal…
4nte Aug 11, 2026
34de666
Merge branch 'main' into PMM-15227-remove-stale-ha-nodes-from-inventory
4nte Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/docs/install-pmm/install-HA-clustered.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@ When you scale PMM HA up or down, **all PMM pods will be recreated**. This happe
- HAProxy continues routing to available pods during rollout
- No data loss (distributed storage)
- Rolling update strategy minimizes downtime
- The Nodes of removed replicas disappear from **Inventory > Nodes** once the remaining pods restart
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
4nte marked this conversation as resolved.
Outdated
Comment thread
4nte marked this conversation as resolved.
Outdated

To scale PMM server replicas:

Expand Down
6 changes: 6 additions & 0 deletions managed/models/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,12 @@ func setupPMMServerHAAgents(q *reform.Querier, params SetupDBParams) error {
// create PMM Server Node and associated Agents in HA mode
logrus.Infof("Setting up PMM Server agents in HA mode, Node ID: %s", params.HANodeID)

// Before the "agent already exists" early return, so restarted replicas still clean up.
err := RemoveStaleHANodes(q, params.HANodeID, params.HAPeers)
Comment thread
4nte marked this conversation as resolved.
Outdated
if err != nil {
return err
}

file, err := os.Open(AgentConfigFilePath)
if err != nil {
return err
Expand Down
128 changes: 126 additions & 2 deletions managed/models/node_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package models
import (
"errors"
"fmt"
"net"
"strings"

"github.com/AlekSi/pointer"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gopkg.in/reform.v1"
Expand Down Expand Up @@ -252,13 +254,19 @@ func CreateNode(q *reform.Querier, nodeType NodeType, params *CreateNodeParams)
}

// RemoveNode removes single Node.
func RemoveNode(q *reform.Querier, id string, mode RemoveMode) error { //nolint:gocognit
func RemoveNode(q *reform.Querier, id string, mode RemoveMode) error {
return removeNode(q, id, mode, false)
}

// removeNode removes a single Node. The allowPMMServerNode flag lifts the ban on Nodes flagged as PMM
// Server Nodes; only the HA cleanup sets it, to reap replicas that are no longer part of the cluster.
func removeNode(q *reform.Querier, id string, mode RemoveMode, allowPMMServerNode bool) error { //nolint:gocognit
n, err := FindNodeByID(q, id)
if err != nil {
return err
}

if n.IsPMMServerNode || id == PMMServerNodeID {
if id == PMMServerNodeID || (!allowPMMServerNode && n.IsPMMServerNode) {
Comment thread
4nte marked this conversation as resolved.
Outdated
return status.Error(codes.PermissionDenied, "PMM Server node can't be removed.")
}

Expand Down Expand Up @@ -334,3 +342,119 @@ func RemoveNode(q *reform.Querier, id string, mode RemoveMode) error { //nolint:
}
return nil
}

// RemoveStaleHANodes removes the PMM Server Nodes of HA replicas that are no longer configured peers,
// e.g. after a scale-down. Peers are the source of truth because they are regenerated from the replica
// count and restart every replica, while a missing memberlist member may just be restarting.
func RemoveStaleHANodes(q *reform.Querier, haNodeID string, haPeers []string) error {
if len(haPeers) == 0 {
Comment thread
4nte marked this conversation as resolved.
return nil
}

l := logrus.WithFields(logrus.Fields{"component": "ha", "ha_node_id": haNodeID})

expected := make(map[string]struct{}, len(haPeers))
for _, peer := range haPeers {
name, ok := haPeerNodeName(peer)
if !ok {
Comment thread
4nte marked this conversation as resolved.
// Trusting the rest would treat a partial list as the whole cluster and remove live replicas.
l.WithField("peer", peer).Warn("Can't read a node name from a PMM_HA_PEERS entry, skipping the removal of stale HA nodes.")
return nil
}
expected[name] = struct{}{}
}

if _, ok := expected[haNodeID]; !ok {
l.WithField("ha_peers", haPeers).Warn("PMM_HA_PEERS doesn't list this node, skipping the removal of stale HA nodes.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log message states skipping remove but in fact the whole func is terminated

return nil
}

nodes, err := FindNodes(q, NodeFilters{})
Comment thread
4nte marked this conversation as resolved.
Outdated
if err != nil {
return fmt.Errorf("failed to list Nodes for stale HA node cleanup: %w", err)
}

for _, node := range nodes {
// Set by HA replicas, and by the PMM Server Node of a non-HA deployment; every other
// Node is one the user monitors.
if !node.IsPMMServerNode {
Comment thread
4nte marked this conversation as resolved.
Outdated
continue
}
if _, ok := expected[node.NodeName]; ok {
continue
}

nodeL := l.WithFields(logrus.Fields{"node_id": node.NodeID, "node_name": node.NodeName})

monitored, err := haNodeMonitoredServices(q, node.NodeID)
if err != nil {
return err
Comment thread
4nte marked this conversation as resolved.
Outdated
}
if len(monitored) != 0 {
nodeL.WithField("service_ids", monitored).Warn("Keeping stale HA node: it still monitors services, which would be removed with it. " +
"Re-add them from a running replica and remove the node from Inventory.")
continue
}

err = removeNode(q, node.NodeID, RemoveCascade, true)
switch {
case err == nil:
nodeL.Info("Removed stale HA node, it is not a part of the cluster anymore.")
case errors.Is(err, reform.ErrNoRows), status.Code(err) == codes.NotFound:
nodeL.Info("Stale HA node was already removed by another replica.")
default:
return fmt.Errorf("failed to remove stale HA node %q: %w", node.NodeName, err)
}
}

return nil
}

// haPeerNodeName maps a PMM_HA_PEERS entry ("pmm-ha-0.pmm-ha.pmm.svc.cluster.local:9761") to a Node
// name: the first label is the pod's PMM_HA_NODE_ID. Reports false for entries with no name, like
// bare IPv4 or IPv6 addresses.
func haPeerNodeName(peer string) (string, bool) {
peer = strings.TrimSpace(peer)
// Test the whole entry before cutting at ":": an unbracketed IPv6 literal would otherwise be cut
// into its first group, and the "2001" of "2001:db8::7" reads like a node name. Only IPv6 entries
// hold more than one colon, bracketed or not, and none of them starts with a name.
if strings.Count(peer, ":") > 1 || net.ParseIP(peer) != nil {
return "", false
}
host, _, _ := strings.Cut(peer, ":")
if net.ParseIP(host) != nil {
return "", false
}
// "/" is memberlist's "name/address" form, "[" a bracketed address; such a label mixes a name
// with an address instead of being one.
label, _, _ := strings.Cut(host, ".")
if label == "" || strings.ContainsAny(label, "/[") {
return "", false
}
return label, true
}

// haNodeMonitoredServices returns the IDs of Services whose exporters run under a replica's pmm-agent.
// Remote instances bind theirs to the replica that added them (see management.RDSService), so removing
// that replica's Node takes them with it.
func haNodeMonitoredServices(q *reform.Querier, nodeID string) ([]string, error) {
Comment thread
4nte marked this conversation as resolved.
pmmAgents, err := FindPMMAgentsRunningOnNode(q, nodeID)
if err != nil {
return nil, err
}

var serviceIDs []string
for _, pmmAgent := range pmmAgents {
agents, err := FindAgents(q, AgentFilters{PMMAgentID: pmmAgent.AgentID})
Comment thread
4nte marked this conversation as resolved.
Outdated
if err != nil {
return nil, err
}
for _, agent := range agents {
if agent.ServiceID != nil {
serviceIDs = append(serviceIDs, *agent.ServiceID)
}
}
}

return serviceIDs, nil
}
157 changes: 157 additions & 0 deletions managed/models/node_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,160 @@ func TestNodeHelpers(t *testing.T) {
require.Len(t, nodes, 2) // PMM Server + HA PMM Server node
})
}

func TestRemoveStaleHANodes(t *testing.T) {
sqlDB := testdb.Open(t, models.SetupFixtures, nil)
t.Cleanup(func() {
require.NoError(t, sqlDB.Close())
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Two HA replica Nodes, one with a node_exporter, plus an unrelated monitored Node.
setup := func(t *testing.T) (*reform.Querier, func(t *testing.T)) {
t.Helper()
db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf))
tx, err := db.Begin()
require.NoError(t, err)
q := tx.Querier

for _, str := range []reform.Struct{
&models.Node{
NodeID: "ha-node-1",
NodeType: models.GenericNodeType,
NodeName: "pmm-ha-1",
Address: models.LocalhostAddr,
IsPMMServerNode: true,
},
&models.Agent{
AgentID: "ha-agent-1",
AgentType: models.PMMAgentType,
RunsOnNodeID: new("ha-node-1"),
},
&models.Node{
NodeID: "ha-node-2",
NodeType: models.GenericNodeType,
NodeName: "pmm-ha-2",
Address: models.LocalhostAddr,
IsPMMServerNode: true,
},
&models.Agent{
AgentID: "ha-agent-2",
AgentType: models.PMMAgentType,
RunsOnNodeID: new("ha-node-2"),
},
&models.Agent{
AgentID: "ha-node-exporter-2",
AgentType: models.NodeExporterType,
PMMAgentID: new("ha-agent-2"),
NodeID: new("ha-node-2"),
},
&models.Node{
NodeID: "monitored-node",
NodeType: models.GenericNodeType,
NodeName: "Monitored Node",
},
} {
require.NoError(t, q.Insert(str), "failed to INSERT %+v", str)
}

teardown := func(t *testing.T) {
t.Helper()
require.NoError(t, tx.Rollback())
}
return q, teardown
}

assertNodeExists := func(t *testing.T, q *reform.Querier, nodeID string) {
t.Helper()
_, err := models.FindNodeByID(q, nodeID)
assert.NoError(t, err)
}

t.Run("RemovesScaledDownReplicaWithItsAgents", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

peers := []string{"pmm-ha-0.pmm-ha.pmm.svc.cluster.local:9761", " pmm-ha-1.pmm-ha.pmm.svc.cluster.local "}
require.NoError(t, models.RemoveStaleHANodes(q, "pmm-ha-1", peers))

assertNodeExists(t, q, "ha-node-1")
_, err := models.FindAgentByID(q, "ha-agent-1")
require.NoError(t, err)

_, err = models.FindNodeByID(q, "ha-node-2")
tests.AssertGRPCErrorCode(t, codes.NotFound, err)

// the removal cascades to the agents of the stale node
for _, agentID := range []string{"ha-agent-2", "ha-node-exporter-2"} {
_, err := models.FindAgentByID(q, agentID)
tests.AssertGRPCErrorCode(t, codes.NotFound, err)
}

// neither monitored nodes nor the pre-HA pmm-server Node are touched
assertNodeExists(t, q, "monitored-node")
assertNodeExists(t, q, models.PMMServerNodeID)
Comment thread
4nte marked this conversation as resolved.
Outdated
})

t.Run("KeepsAllReplicasWhenNothingWasScaledDown", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

// a dotless host with a port is what a hand-written PMM_HA_PEERS looks like
peers := []string{"pmm-ha-1.pmm-ha:9761", "pmm-ha-2:9761"}
require.NoError(t, models.RemoveStaleHANodes(q, "pmm-ha-1", peers))

assertNodeExists(t, q, "ha-node-1")
assertNodeExists(t, q, "ha-node-2")
})

t.Run("KeepsScaledDownReplicaThatStillMonitorsServices", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

// an exporter for a remote instance, bound to the scaled-down replica's pmm-agent
for _, str := range []reform.Struct{
&models.Service{
ServiceID: "rds-service",
ServiceType: models.MySQLServiceType,
ServiceName: "RDS instance",
NodeID: "monitored-node",
Address: new("rds.example.com"),
Port: new(uint16(3306)),
},
&models.Agent{
AgentID: "rds-exporter",
AgentType: models.MySQLdExporterType,
PMMAgentID: new("ha-agent-2"),
ServiceID: new("rds-service"),
},
} {
require.NoError(t, q.Insert(str), "failed to INSERT %+v", str)
}

peers := []string{"pmm-ha-0.pmm-ha:9761", "pmm-ha-1.pmm-ha:9761"}
require.NoError(t, models.RemoveStaleHANodes(q, "pmm-ha-1", peers))

assertNodeExists(t, q, "ha-node-2")
_, err := models.FindAgentByID(q, "rds-exporter")
require.NoError(t, err)
})

t.Run("DoesNothingWhenPeersCantBeTrusted", func(t *testing.T) {
q, teardown := setup(t)
defer teardown(t)

for _, peers := range [][]string{
{"pmm-ha-2.pmm-ha:9761"}, // lists only the other replica
{"10.244.1.7:9761", "10.244.2.8:9761"}, // no node names to read
{"pmm-ha-1.pmm-ha:9761", "10.244.2.8:9761"}, // mixed: one entry hides a live replica
{"pmm-ha-1.pmm-ha:9761", "pmm-ha-2/10.0.0.2"}, // memberlist "name/address" form
{"pmm-ha-1.pmm-ha:9761", "2001:db8::7"}, // an unbracketed IPv6 entry hides a live replica
{"pmm-ha-1.pmm-ha:9761", "[2001:db8::7]:9761"},
nil,
} {
require.NoError(t, models.RemoveStaleHANodes(q, "pmm-ha-1", peers))

assertNodeExists(t, q, "ha-node-1")
assertNodeExists(t, q, "ha-node-2")
}
})
}
Loading