Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

State the retention condition.

This sentence guarantees Node removal after restart. Cleanup retains a stale Node when it still monitors Services. Cleanup also skips removal when peer data is not trusted.

State that only eligible stale replica Nodes disappear. Explain that operators must move monitored Services to a running replica before removal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@documentation/docs/install-pmm/install-HA-clustered.md` at line 832, Revise
the statement about removed replica Nodes in the HA cluster cleanup
documentation to say that only eligible stale Nodes disappear after remaining
pods restart. Mention that removal is skipped when peer data is untrusted or the
Node still monitors Services, and instruct operators to move those Services to a
running replica before removal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
- The Nodes of removed replicas disappear from **Inventory > Nodes** once the remaining pods restart
- The Nodes of removed replicas get removed from **Inventory > Nodes** once the remaining pods restart

disappear sounded more like an unwanted consequence rather than a necessary cleanup action.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is stated unconditionally, but the cleanup is skipped in several configurations: PMM_HA_PEERS built from bare IPs (10.244.1.7:9761,...), an empty peer list, a list that doesn't include the local pod, or any entry the parser can't read a name from (a trailing comma is enough). In those cases the Nodes stay in Inventory and an operator will read this bullet as a broken promise.

Suggest qualifying it — the Nodes are removed when PMM_HA_PEERS carries resolvable node names — and mentioning that a WARN is logged when the cleanup is skipped, so there's something to grep for.


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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Altitude: a cosmetic cleanup can stop the server from booting.

RemoveStaleHANodes runs inside the schema-migration transaction and returns hard errors, so anything it trips over aborts db.InTransaction in migrateDB, and managed/cmd/pmm-managed/main.go:640 eventually calls l.Fatalf("Could not migrate DB: timeout").

Candidates that reach that path today: PermissionDenied on the pre-HA pmm-server Node (see the comment on RemoveStaleHANodes), a NotFound out of the haNodeMonitoredServices pre-check while another replica commits, a row-lock/serialization failure when three replicas restart at once.

Tidying stale rows out of Inventory should never keep a PMM Server replica from starting. Suggest logging the error and continuing instead of returning it — nothing up the stack can recover from it.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With allowPMMServerNode = true, the only remaining protection is a mutable package variable.

PMMServerNodeID starts as "pmm-server" (managed/models/node_helpers.go / node_model.go:44) but setupPMMServerHAAgents reassigns it to this replica's UUID at managed/models/database.go:1561 and :1610after the cleanup call at :1532.

migrateDB retries SetupDB in a loop (managed/cmd/pmm-managed/main.go:637), so on a second attempt in the same process (attempt 1 reached :1610, then the COMMIT failed and rolled back) RemoveStaleHANodes runs with PMMServerNodeID pointing at a Node that no longer exists. The pre-HA pmm-server Node is then unguarded and, with the flag lifted, gets deleted outright.

RemoveAgent's id == PMMServerAgentID guard (managed/models/agent_helpers.go:1450) has the same order dependence. A guard that this very code path mutates is a fragile place to hang "can't delete the server's own node" on — better keyed off the caller's known NodeID.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does the headline scenario — scaling down to one replica — actually reach the cleanup?

If the chart emits an empty PMM_HA_PEERS for a single-replica cluster (nothing to gossip with), this early return fires. If it emits a list that doesn't include the surviving pod, the expected[haNodeID] guard below fires. Either way the two departed replicas' Nodes stay in Inventory forever, which is the case the PR is meant to fix.

Nothing here pins down what the chart produces for replicas: 1, and there's no test for it. Worth confirming against the chart before merge — and if the list can legitimately be empty, that state needs its own handling.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One empty entry in PMM_HA_PEERS silently disables the feature.

managed/cmd/pmm-managed/main.go:805 splits the env var on ,, so "pmm-ha-0.pmm-ha:9761,pmm-ha-1.pmm-ha:9761," — a trailing comma, easy to get out of a Helm join over a list with a blank element — yields a third, empty element. haPeerNodeName("") returns ok=false on the label == "" check, and the whole cleanup returns nil with only a WARN buried in the startup log.

An empty entry carries no information about the cluster, so it isn't the "partial list" hazard this branch guards against. Suggest skipping blank entries (if peer == "" { continue }) rather than treating them as untrusted.

// 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.")
return nil
}

nodes, err := FindNodes(q, NodeFilters{})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Loads the whole nodes table to keep a handful of rows.

Every HA replica boot materializes every monitored Node inside the migration transaction, only for the loop below to discard everything without IsPMMServerNode. On an install with thousands of Nodes that's a full scan plus full row hydration per replica start.

NodeFilters (managed/models/node_helpers.go:92) has no IsPMMServerNode field; adding one — or issuing q.SelectAllFrom(NodeTable, "WHERE is_pmm_server_node") directly here — makes the cost proportional to the replica count instead of the inventory size.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The legacy pmm-server Node is classified as stale.

The comment right above acknowledges that a non-HA deployment's PMM Server Node also carries the flag, but nothing skips it. On a DB that was first set up non-HA, setupPMMServerAgents (managed/models/database.go:1618) created a Node with NodeID = NodeName = "pmm-server" and IsPMMServerNode: true. That name is never in expected, so the loop treats it as a scaled-down replica. Two outcomes:

  • Usually its pmm-agent still owns the pmm-server-postgresql exporter, so len(monitored) != 0 and every replica logs Keeping stale HA node ... Re-add them from a running replica and remove the node from Inventory on every single boot. That advice can't be followed: both public delete paths (managed/services/inventory/nodes.go:340, managed/services/management/node.go:181) go through models.RemoveNode -> removeNode(..., allowPMMServerNode=false) -> PermissionDenied: PMM Server node can't be removed.
  • If that service is gone, monitored is empty and removeNode(q, "pmm-server", RemoveCascade, true) hits the id == PMMServerNodeID guard on line 269, returns PermissionDenied, falls into the default: branch on line 405 and aborts the migration transaction. managed/cmd/pmm-managed/main.go:637 then retries for 5 minutes and calls l.Fatalf("Could not migrate DB: timeout") — the replica crash-loops.

Reproduced locally: with the fixture's pmm-server-postgresql service deleted, RemoveStaleHANodes returns failed to remove stale HA node "pmm-server": rpc error: code = PermissionDenied desc = PMM Server node can't be removed.

Suggested fix: if node.NodeID == PMMServerNodeID { continue } at the top of the loop, and tolerate PermissionDenied in the switch below.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The pre-check doesn't get the race tolerance removeNode was given.

Four lines down, the switch deliberately tolerates reform.ErrNoRows / codes.NotFound because another replica may be reaping the same Node concurrently. This return err bypasses that.

haNodeMonitoredServices -> FindAgents performs a FindAgentByID(filters.PMMAgentID) existence probe (managed/models/agent_helpers.go:241). If replica A commits the deletion of ha-node-2's pmm-agent while replica B sits between its FindNodes snapshot and this call, B gets codes.NotFound here and aborts the whole migration.

Same errors.Is(err, reform.ErrNoRows) || status.Code(err) == codes.NotFound -> continue treatment would close it.

}
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The safety check misses Services attached to the Node itself.

This only walks agents whose pmm_agent_id is one of the replica's pmm-agents, so a Service bound to the stale Node via services.node_id is invisible here. That shape exists in the codebase — managed/services/checks/checks.go:1377 special-cases service.NodeID == models.PMMServerNodeID, and under HA PMMServerNodeID is the local replica's Node.

If such a Service's exporter runs under a different replica's pmm-agent, monitored comes back empty, the guard passes, and removeNode(..., RemoveCascade, true) reaches managed/models/node_helpers.go:318 and calls RemoveService(..., RemoveCascade) on it. The Service and its agents are deleted silently — no warning, which is exactly what the guard exists to prevent.

Worth also counting SELECT ... FROM services WHERE node_id = $1 (and agents bound via node_id) before deciding a Node is safe to reap.

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})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

N+1 queries, credential decryption, and duplicate IDs, all to read ServiceID.

  • FindAgents runs a FindAgentByID existence probe (managed/models/agent_helpers.go:241) before its real query, once per pmm-agent.
  • FindPMMAgentsRunningOnNode calls DecryptAgent on every row (managed/models/agent_helpers.go:423), pulling encrypted passwords through the encryption layer inside the migration transaction just to test agent.ServiceID != nil.
  • The result carries duplicates straight into the operator-facing warning — the test run logged it still monitors services [5bf3df6d-361d-42dd-9dd2-3d0b3573ab24 5bf3df6d-361d-42dd-9dd2-3d0b3573ab24] because two exporters point at the same service.

One query replaces the helper:

SELECT DISTINCT service_id FROM agents
 WHERE pmm_agent_id IN (SELECT agent_id FROM agents WHERE runs_on_node_id = $1 AND agent_type = 'pmm-agent')
   AND service_id IS NOT NULL

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This assertion passes by accident, and the interesting path is untested.

The comment above says the pre-HA pmm-server Node isn't touched, but it survives only because SetupFixtures attaches pmm-server-postgresql to it, so the run takes the len(monitored) != 0 branch. The test log shows it:

Keeping stale HA node "pmm-server" (pmm-server): it still monitors services [5bf3df6d-... 5bf3df6d-...]

Nothing here exercises protection for PMM Server Nodes. Drop that one fixture service and RemoveStaleHANodes returns PermissionDenied, failing this subtest at the require.NoError on line 343.

Worth an explicit case for a PMM-Server-flagged Node with no monitored services. Also assertNodeExists uses assert.NoError (line 335) — require would stop the subtest at the real failure instead of cascading.

})

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