Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5c29181
PMM-1519 Change QAN-PGSM fix for internal error.
JiriCtvrtka Jul 27, 2026
f9589e0
PMM-15191 Refactor, lint.
JiriCtvrtka Jul 27, 2026
af06aad
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 29, 2026
51180cf
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 29, 2026
7bb7f6c
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 29, 2026
65a63a2
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 30, 2026
15b7b99
PMM-15191 Move optional bool env parsing into the env package.
JiriCtvrtka Jul 30, 2026
a0e8d9c
PMM-15191 Enforce the internal PG QAN env override on the agent row.
JiriCtvrtka Jul 30, 2026
24e32a9
PMM-15191 Cover the branches that let the env override stand down.
JiriCtvrtka Jul 30, 2026
8b865ff
PMM-15191 Stop predicting generated agent IDs in the env var test.
JiriCtvrtka Jul 30, 2026
fb853e9
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 30, 2026
db66c3f
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Jul 31, 2026
25d5d76
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 4, 2026
cc97e1d
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 17, 2026
d006f10
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 20, 2026
4714b4d
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 20, 2026
07c5e00
PMM-15191 Report an unparsable PMM_ENABLE_INTERNAL_PG_QAN via an error
JiriCtvrtka Aug 20, 2026
219d46d
PMM-15191 Identify the internal PG QAN agent by its service, not just…
JiriCtvrtka Aug 20, 2026
53d9efe
PMM-15191 Use the service-aware lookup for the internal PG QAN agent
JiriCtvrtka Aug 20, 2026
237caac
PMM-15191 Reject a Change*Agent request for the wrong agent type befo…
JiriCtvrtka Aug 20, 2026
b1d6874
PMM-15191 Cover the wrong-agent-type rejection and the guard's remain…
JiriCtvrtka Aug 20, 2026
e6a4023
PMM-15191 Close the race between two concurrent adds of the internal …
JiriCtvrtka Aug 20, 2026
63c37f2
PMM-15191 Key the internal PG QAN duplicate check off the Service, no…
JiriCtvrtka Aug 21, 2026
898f020
PMM-15191 Guard the internal PG QAN agent's removal via ManagementSer…
JiriCtvrtka Aug 21, 2026
2637fdd
PMM-15191 Avoid re-fetching the agent row in executeAgentChange
JiriCtvrtka Aug 21, 2026
30bd958
PMM-15191 Silence gocognit on ApplyAgentChange per percona-robot review
JiriCtvrtka Aug 21, 2026
e1471ee
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 21, 2026
0ea5008
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 24, 2026
4142c43
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Aug 27, 2026
6db6865
PMM-15191 Use SELECT ... FOR UPDATE instead of pg_advisory_xact_lock
JiriCtvrtka Aug 28, 2026
4bceb8e
Merge remote-tracking branch 'origin/PMM-15191-admin-change-error' in…
JiriCtvrtka Aug 28, 2026
1e774c9
PMM-15191 Move CheckInternalPgQANRemoval into RemoveAgent, the real c…
JiriCtvrtka Aug 28, 2026
25fc243
PMM-15191 Key IsInternalPgQANAgent on the Service alone, drop the pmm…
JiriCtvrtka Aug 28, 2026
faebf94
PMM-15191 Use FOR NO KEY UPDATE, drop the unreachable empty-serviceID…
JiriCtvrtka Aug 28, 2026
e2e4dd1
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Sep 1, 2026
731cb85
Merge remote-tracking branch 'origin/PMM-15191-admin-change-error' in…
JiriCtvrtka Sep 1, 2026
01a6255
PMM-15191 Split the removal and duplicate guards out of this PR
JiriCtvrtka Sep 1, 2026
f76eb77
PMM-15191 Address review: take the ApplyAgentChange benefit, tidy errors
JiriCtvrtka Sep 1, 2026
4c2e81d
PMM-15191 Drop a stray blank line left by removing the split-out tests
JiriCtvrtka Sep 1, 2026
913180d
PMM-15191 Cleanup pass: fewer queries, less duplication, shorter comm…
JiriCtvrtka Sep 1, 2026
9333c73
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Sep 2, 2026
939f65c
PMM-15191 Drop a comment reference to a symbol split into the follow-…
JiriCtvrtka Sep 2, 2026
596b89a
Merge branch 'main' into PMM-15191-admin-change-error
JiriCtvrtka Sep 3, 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
65 changes: 64 additions & 1 deletion managed/models/agent_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,58 @@ func FindAgents(q *reform.Querier, filters AgentFilters) ([]*Agent, error) {
return agents, nil
}

// IsInternalPgQANAgent reports whether the Agent is the QAN Agent of PMM Server's own PostgreSQL
// Service.
//
// Keyed on the Service alone, not the pmm-agent: Service names are unique, so the name already
// excludes a remote instance's QAN Agent (RDS/Azure discovery attaches those to PMM Server's own
// pmm-agent too). Adding pmm_agent_id == PMMServerAgentID would also be wrong, because
// PMMServerAgentID is a mutable process global, reassigned in HA setup and from the pmm-agent
// config file.
func IsInternalPgQANAgent(q *reform.Querier, agent *Agent) (bool, 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.

This and FindInternalPgQANAgent disagree on what "the internal agent" is, and the difference is a silent bypass.

Here: agent_type AND pmm_agent_id == PMMServerAgentID AND service name. In FindInternalPgQANAgent (line 388), which is what the settings API acts on: service name AND type, no pmm_agent_id.

models.PMMServerAgentID isn't a constant — it's a mutable process global, reassigned in HA setup (managed/models/database.go:1656) and from the pmm-agent config file (managed/models/database.go:1608).

So this sequence leaves an agent that ChangeSettings happily toggles but all three guards consider foreign: with the variable unset, remove the internal QAN agent, register a second pmm-agent, re-add the QAN agent for pmm-server-postgresql under that pmm-agent (your own RejectAddingASecondInternalQANAgentUnderAnotherPMMAgent test proves this shape is constructible), then set PMM_ENABLE_INTERNAL_PG_QAN=true.

The pmm_agent_id conjunct also isn't needed for the reason the comment gives. Service names are unique, so RDS/Azure remote instances necessarily have a different service_name — the service-name check alone already excludes them. Dropping the conjunct makes the two functions agree and closes the gap.

if agent.AgentType != QANPostgreSQLPgStatementsAgentType {
return false, nil
}

serviceID := pointer.GetString(agent.ServiceID)
if serviceID == "" {
return false, nil
}

service, err := FindServiceByID(q, serviceID)
if err != nil {
return false, err
}

return service.ServiceName == PMMServerPostgreSQLServiceName, nil
}

// FindInternalPgQANAgent returns the QAN Agent of PMM Server's own PostgreSQL Service.
//
// It returns NotFound when PMM Server has no such Service, which is the normal state in HA mode
// where PMM Server runs against an external PostgreSQL and the fixtures do not create it.
func FindInternalPgQANAgent(q *reform.Querier) (*Agent, error) {
service, err := FindServiceByName(q, PMMServerPostgreSQLServiceName)
if err != nil {
return nil, err
}

// Queried directly rather than through FindAgents, which re-validates a ServiceID filter with
// FindServiceByID -- a third round trip for the Service row just loaded above.
structs, err := q.SelectAllFrom(AgentTable, "WHERE service_id = $1 AND agent_type = $2 ORDER BY agent_id",
service.ServiceID, QANPostgreSQLPgStatementsAgentType)
if err != nil {
return nil, err
}
if len(structs) == 0 {
return nil, status.Errorf(codes.NotFound, "QAN Agent for the %q Service not found.", PMMServerPostgreSQLServiceName)
}

agent := DecryptAgent(*structs[0].(*Agent)) //nolint:forcetypeassert

return &agent, nil
}

// FindAgentByID finds Agent by ID.
func FindAgentByID(q *reform.Querier, id string) (*Agent, error) {
if id == "" {
Expand Down Expand Up @@ -1208,12 +1260,23 @@ func (p *ChangeAgentParams) AffectsConnection() bool {
}

// ChangeAgent changes agent parameters based on agent type.
func ChangeAgent(q *reform.Querier, agentID string, params *ChangeAgentParams) (*Agent, error) { //nolint:cyclop,maintidx
func ChangeAgent(q *reform.Querier, agentID string, params *ChangeAgentParams) (*Agent, error) {
row, err := FindAgentByID(q, agentID)
if err != nil {
return nil, err
}

return ApplyAgentChange(q, row, params)
}

// ApplyAgentChange changes agent parameters on an already-loaded Agent row, based on agent type.
//
// Callers that already had to load the row to inspect it before changing it (e.g. to check its
// type or a precondition) can pass it here directly, instead of ChangeAgent re-fetching the same
// row from the database.
func ApplyAgentChange(q *reform.Querier, row *Agent, params *ChangeAgentParams) (*Agent, error) { //nolint:cyclop,gocognit,maintidx
var err error

// Handle common fields first
if params.Enabled != nil {
row.Disabled = !(*params.Enabled)
Expand Down
124 changes: 89 additions & 35 deletions managed/services/inventory/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package inventory

import (
"context"
"os"
"strings"

"github.com/AlekSi/pointer"
Expand Down Expand Up @@ -234,7 +233,7 @@ func (as *AgentsService) ChangeNodeExporter(ctx context.Context, agentID string,
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.NodeExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -334,7 +333,7 @@ func (as *AgentsService) ChangeMySQLdExporter(ctx context.Context, agentID strin
ConnectionTimeout: duration.OptionalFromProto(p.ConnectionTimeout),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.MySQLdExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -438,7 +437,7 @@ func (as *AgentsService) ChangeMongoDBExporter(
ConnectionTimeout: duration.OptionalFromProto(p.ConnectionTimeout),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.MongoDBExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -541,7 +540,7 @@ func (as *AgentsService) ChangeQANMySQLPerfSchemaAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANMySQLPerfSchemaAgentType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -647,7 +646,7 @@ func (as *AgentsService) ChangeQANMySQLSlowlogAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANMySQLSlowlogAgentType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -741,7 +740,7 @@ func (as *AgentsService) ChangePostgresExporter(
ConnectionTimeout: duration.OptionalFromProto(p.ConnectionTimeout),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.PostgresExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -832,7 +831,7 @@ func (as *AgentsService) ChangeValkeyExporter(ctx context.Context, agentID strin
ConnectionTimeout: duration.OptionalFromProto(p.ConnectionTimeout),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.ValkeyExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -929,7 +928,7 @@ func (as *AgentsService) ChangeQANMongoDBProfilerAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANMongoDBProfilerAgentType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1026,7 +1025,7 @@ func (as *AgentsService) ChangeQANMongoDBMongologAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANMongoDBMongologAgentType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1113,7 +1112,7 @@ func (as *AgentsService) ChangeProxySQLExporter(
ConnectionTimeout: duration.OptionalFromProto(p.ConnectionTimeout),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.ProxySQLExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1210,25 +1209,10 @@ func (as *AgentsService) ChangeQANPostgreSQLPgStatementsAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANPostgreSQLPgStatementsAgentType, params)
if err != nil {
return nil, err
}
// Check if we're trying to modify the internal PostgreSQL QAN agent and if the environment variable is set
envVar, exists := os.LookupEnv(env.EnableInternalPgQAN)
if exists && envVar != "" {
a, err := models.FindAgentByID(as.db.Querier, agentID)
if err != nil {
return nil, status.Errorf(codes.NotFound, "agent with ID %q not found", agentID)
}
if pointer.GetString(a.PMMAgentID) == models.PMMServerAgentID {
return nil, status.Errorf(
codes.FailedPrecondition,
"QAN for PMM's internal PostgreSQL server is set to %s via an environment variable.",
envVar,
)
}
}

pgStatementsAgent, ok := agent.(*inventoryv1.QANPostgreSQLPgStatementsAgent)
if !ok {
Expand Down Expand Up @@ -1324,7 +1308,7 @@ func (as *AgentsService) ChangeQANPostgreSQLPgStatMonitorAgent(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.QANPostgreSQLPgStatMonitorAgentType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1409,7 +1393,7 @@ func (as *AgentsService) ChangeRDSExporter(ctx context.Context, agentID string,
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.RDSExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1506,7 +1490,7 @@ func (as *AgentsService) ChangeExternalExporter(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.ExternalExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1593,7 +1577,7 @@ func (as *AgentsService) ChangeAzureDatabaseExporter(
MetricsResolutions: convertMetricsResolutions(p.MetricsResolutions),
}

agent, err := as.executeAgentChange(ctx, agentID, params)
agent, err := as.executeAgentChange(ctx, agentID, models.AzureDatabaseExporterType, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1621,7 +1605,7 @@ func (as *AgentsService) ChangeNomadAgent(ctx context.Context, agentID string, p
SkipConnectionCheck: true,
}

agent, err := as.executeAgentChange(ctx, agentID, changeParams)
agent, err := as.executeAgentChange(ctx, agentID, models.NomadAgentType, changeParams)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1712,7 +1696,7 @@ func (as *AgentsService) ChangeRTAMongoDBAgent(
changeParams.RTAOptions = models.RTAOptionsFromRequest(p.RtaOptions)
}

ag, err := as.executeAgentChange(ctx, agentID, changeParams)
ag, err := as.executeAgentChange(ctx, agentID, models.RTAMongoDBAgentType, changeParams)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1768,6 +1752,51 @@ func unexpectedAgentTypeError(agent inventoryv1.Agent) error {
return status.Errorf(codes.Internal, "unexpected agent type %T", agent)
}

// checkInternalPgQANEnvOverride rejects a request that would flip the enabled state of the QAN agent
// of PMM's internal PostgreSQL server away from the state pinned by the PMM_ENABLE_INTERNAL_PG_QAN
// environment variable.
//
// The agent argument is the stored row, before the requested change is applied.
//
// The guard deliberately stays in the service layer rather than moving into
// models.ApplyAgentChange: Server.handleInternalQANToggle is the legitimate actor for this exact
// state and calls ApplyAgentChange directly, so a guard down there would make the settings API trip
// over its own pin.
func checkInternalPgQANEnvOverride(q *reform.Querier, agent *models.Agent, enable *bool) error {
// Only a request that actually flips the enabled state can contradict the variable.
if enable == nil || *enable == !agent.Disabled {
return nil
}

// Read before IsInternalPgQANAgent, which costs a query: with the variable unset -- the default
// -- nothing is pinned and there is nothing to check.
enabledByEnv, lookupErr := env.LookupBool(env.EnableInternalPgQAN)
if enabledByEnv == nil && lookupErr == nil {
return nil
}

internal, err := models.IsInternalPgQANAgent(q, agent)
if err != nil || !internal {
return err
}

if lookupErr != nil {
// pmm-managed-init rejects an unparsable value before PMM Server starts, so reaching here
// means that validation was bypassed. The intent to pin is clear even though the value is
// not, so refuse rather than silently unpin.
return status.Errorf(codes.FailedPrecondition, "QAN for PMM's internal PostgreSQL server is configured via an environment variable: %s.", lookupErr)
}
if *enable == *enabledByEnv {
return nil
}

return status.Errorf(
codes.FailedPrecondition,
"QAN for PMM's internal PostgreSQL server is set to %t via an environment variable.",
*enabledByEnv,
)
}

// Helper function to convert custom labels from protobuf to model format.
func convertCustomLabels(customLabels *common.StringMap) *map[string]string {
if customLabels != nil {
Expand Down Expand Up @@ -1814,11 +1843,36 @@ func convertMetricsResolutions(mrs *common.MetricsResolutions) *models.ChangeMet
}

// Helper function to execute agent change and build response.
func (as *AgentsService) executeAgentChange(ctx context.Context, agentID string, params *models.ChangeAgentParams) (inventoryv1.Agent, error) { //nolint:ireturn
//
// The expectedType argument is the agent type that the calling Change*Agent method knows how to
// convert. The inventory API picks that method from the request payload and not from the type of the
// agent being changed, so a request can name an agent of any type. Checking the type here, inside
// the transaction, turns that into a rejected request; without it the change is committed and only
// then fails the type assertion in the caller, leaving the agent modified, pmm-agent not notified
// and the client with an internal error.
//
// The expectedType argument restates what the caller's own type assertion on the result already says, and the
// compiler cannot tie the two together: keep them in sync, or a valid request becomes InvalidArgument.
func (as *AgentsService) executeAgentChange(ctx context.Context, agentID string, expectedType models.AgentType, params *models.ChangeAgentParams) (inventoryv1.Agent, error) { //nolint:ireturn,lll
var agent inventoryv1.Agent

err := as.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error {
updatedAgent, err := models.ChangeAgent(tx.Querier, agentID, params)
// Returning an error rolls the transaction back, so a rejected request leaves the agent untouched.
currentAgent, err := models.FindAgentByID(tx.Querier, agentID)
if err != nil {
return err
}

if currentAgent.AgentType != expectedType {

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 precheck, not the guard rework, is what fixes the reported bug — worth reflecting in the PR description.

The old guard returned codes.FailedPrecondition, which runtime.HTTPStatusFromCode maps to 400 (utils/errors/errors.go:101), so it could never have produced the "Internal server error." in the ticket. The only codes.Internal on that path is unexpectedAgentTypeError (line 1763), reached when the agent ID names an agent of a different type — and that's trivially hit, because pmm-admin change-agent qan-postgresql-pgstatements-agent <id> (admin/commands/inventory/inventory.go:119) does no client-side type check and people mix up the pgstatements and pgstatmonitor agents routinely.

This check converts that into InvalidArgument/400 and rolls back, so PMM-15191 is genuinely fixed. Good change, and the one part that must survive if the rest of the PR gets split up. Also worth a line in the ticket, since the triage comments left the 400-vs-500 mismatch as an open question — this is the answer to it.

return status.Errorf(codes.InvalidArgument, "Agent with ID %s has type %s, expected %s.", agentID, currentAgent.AgentType, expectedType)
}

err = checkInternalPgQANEnvOverride(tx.Querier, currentAgent, params.Enabled)
if err != nil {
return err
}

updatedAgent, err := models.ApplyAgentChange(tx.Querier, currentAgent, params)
if err != nil {
return err
}
Expand Down
Loading
Loading