Skip to content

PMM-15191 Reject a Change*Agent request for the wrong agent type before it commits. - #5703

Open
JiriCtvrtka wants to merge 43 commits into
mainfrom
PMM-15191-admin-change-error
Open

PMM-15191 Reject a Change*Agent request for the wrong agent type before it commits.#5703
JiriCtvrtka wants to merge 43 commits into
mainfrom
PMM-15191-admin-change-error

Conversation

@JiriCtvrtka

@JiriCtvrtka JiriCtvrtka commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PMM-15191

What is done

Fixes the reported "Internal server error." when changing the QAN agent of
PMM's internal PostgreSQL, and reworks the PMM_ENABLE_INTERNAL_PG_QAN
guard that the ticket points at.

The 500 itself was not the env-var guard. That guard returned
codes.FailedPrecondition, which runtime.HTTPStatusFromCode maps to 400
(utils/errors/errors.go:100), so it could never have produced the reported
error. The only codes.Internal on that path is unexpectedAgentTypeError,
reached when the agent ID names an agent of a different type than the
Change*Agent method being called — trivially hit, because
pmm-admin change-agent has a separate subcommand per agent type
(admin/commands/inventory/inventory.go:119), each taking a bare agent ID
with no client-side check that the ID belongs to the type named, and the
pgstatements and pgstatmonitor agents are easy to mix up. The inventory API
picks the method from the request payload, not from the type of the agent
being changed, so nothing rejected the mismatch.

executeAgentChange now takes the agent type its caller can convert and
checks it against the stored row inside the transaction, before anything
is applied — turning that case into InvalidArgument/400 with the agent
untouched. Previously the change was committed, then the type assertion in
the caller failed, leaving the agent modified, pmm-agent never notified, and
the client with a 500. This is the change that closes the ticket; the
triage-recommended fix (move the guard earlier, parse the bool) addresses the
over-rejection and the =false behaviour, not the 500. It cannot be smaller
than it is: one bug reachable from all 17 Change*Agent methods means every
one of those call sites has to pass the type it handles, because
models.ChangeAgentParams carries no type discriminator.

The env-var guard was at the end of ChangeQANPostgreSQLPgStatementsAgent,
after the change had already committed. Problems: the rejected change was
applied anyway; it was bypassable by pointing a different Change*Agent
method at the internal agent; it over-rejected any change to any agent under
PMM Server's pmm-agent; and PMM_ENABLE_INTERNAL_PG_QAN=false behaved like a
pin to "enabled" because the check was only "set and non-empty".

checkInternalPgQANEnvOverride replaces it, inside the transaction, and
rejects only a request that actually flips the enabled state, targets the
internal agent, and contradicts a boolean value of the variable. Unrelated
parameters, a no-op request, moving toward the pinned state, other agent
types, and remote-instance QAN agents all stay allowed. It deliberately stays
in the service layer rather than moving into models, because
Server.handleInternalQANToggle is the legitimate actor for this exact state
and calls ApplyAgentChange directly — a guard in models would make the
settings API trip over its own pin.

Identifying the internal agent. Agent type + pmm_agent_id was not
enough: RDS/Azure discovery attaches a remote PostgreSQL instance's QAN agent
to PMM Server's own pmm-agent the same way, so that pair matched agents with
nothing to do with PMM's own database. models.IsInternalPgQANAgent and
models.FindInternalPgQANAgent add the Service name, and Server.GetSettings
and Server.handleInternalQANToggle call the new lookup directly. Keyed on
the Service alone: names are unique, and PMMServerAgentID is a mutable
process global reassigned in HA setup and from the pmm-agent config file, so
requiring it to match would let the agent evade the check the moment it runs
under a different but perfectly valid pmm-agent.

Supporting changes

  • env.LookupBool distinguishes unset / boolean / unparsable instead of
    collapsing unparsable into "unset". GetBool delegates to it.
  • ChangeAgent split into a thin ID-based wrapper plus ApplyAgentChange,
    which operates on an already-loaded row. Both executeAgentChange and
    Server.handleInternalQANToggle had already loaded it, so the settings
    toggle no longer re-runs FindServiceByName + FindAgents and a second
    Reload/DecryptAgent pass. Note ChangeAgent now has no production
    caller and is kept as exported API — say the word and I'll drop it.
  • FindInternalPgQANAgent queries the agents table directly rather than via
    FindAgents, which re-validates a ServiceID filter with
    FindServiceByID — a third round trip for the Service row just loaded.
    This is on GetSettings, which the UI polls.
  • checkInternalPgQANEnvOverride reads the variable before
    IsInternalPgQANAgent, so the default (unset) case costs no query.
  • tests.UnsetEnv, next to the existing SetTestIDReader: there is no
    counterpart to t.Setenv for unsetting, and setting a boolean to an empty
    string is a configuration error, not "unset".

Split out of this PR

The removal guard and the duplicate guard moved to PMM-15421, per review.
Both are user-visible restrictions on a default install rather than parts of
this bug, and need a doc update and a release note that a bug ticket would
not get.

Tests

managed/services/inventory/agents_test.go covers, for the env-var guard:
rejection when disabling while pinned enabled and when enabling while pinned
disabled, with the agent and its unrelated parameters left untouched;
acceptance for unrelated parameters, a no-op request, moving toward the pinned
state, other agent types of PMM Server, and a remote PostgreSQL instance's QAN
agent under PMM Server's own pmm-agent; an unparsable value; and any change
when the variable is unset. Subtests decide the variable before setup(t), so
the state the fixtures create and the pinned state agree the way they do on a
real server, and an ambient value in CI cannot change what is created.

For the type precheck: TestChangeAgentRejectsAgentOfAnotherType plus
RejectRequestThroughParamsOfAnotherAgentType, both asserting the request is
refused and the stored agent is left unmodified.

managed/utils/env/env_test.go covers LookupBool across unset, true,
false, 1, 0, set-but-empty, and non-boolean.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.54945% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 45.87%. Comparing base (31318c7) to head (596b89a).
⚠️ Report is 165 commits behind head on main.

Files with missing lines Patch % Lines
managed/models/agent_helpers.go 15.38% 22 Missing ⚠️
managed/services/inventory/agents.go 73.33% 11 Missing and 1 partial ⚠️
managed/utils/tests/env.go 0.00% 9 Missing ⚠️
managed/services/server/server.go 33.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5703      +/-   ##
==========================================
+ Coverage   43.59%   45.87%   +2.27%     
==========================================
  Files         415      218     -197     
  Lines       43134    28224   -14910     
==========================================
- Hits        18804    12947    -5857     
+ Misses      22454    13892    -8562     
+ Partials     1876     1385     -491     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JiriCtvrtka

Copy link
Copy Markdown
Contributor Author

@copilot review

@JiriCtvrtka

Copy link
Copy Markdown
Contributor Author

@copilot review

@JiriCtvrtka
JiriCtvrtka marked this pull request as ready for review July 30, 2026 16:12
@JiriCtvrtka
JiriCtvrtka requested a review from a team as a code owner July 30, 2026 16:12
@JiriCtvrtka
JiriCtvrtka requested review from 4nte, ademidoff and maxkondr and removed request for a team July 30, 2026 16:12
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bf55784-69f4-4ca0-84b1-0bcf74975eea

📥 Commits

Reviewing files that changed from the base of the PR and between 2637fdd and 30bd958.

📒 Files selected for processing (1)
  • managed/models/agent_helpers.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • percona/pmm-qa (manual)
  • percona/pmm (manual)
🚧 Files skipped from review as they are similar to previous changes (1)
  • managed/models/agent_helpers.go

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


Walkthrough

The change adds agent-type validation, tri-state environment parsing, shared internal PostgreSQL QAN lookup, transactional duplicate prevention, and removal protection. Invalid changes remain unapplied. Tests cover routing, rollback, environment states, concurrency, and service removal.

Changes

Internal PostgreSQL QAN safeguards

Layer / File(s) Summary
Internal QAN lookup and environment contract
managed/models/agent_helpers.go, managed/services/server/server.go, managed/utils/env/*
Shared helpers identify the internal PostgreSQL QAN agent. LookupBool distinguishes unset, valid, and invalid values. GetBool retains its fallback behavior.
Transactional agent changes and duplicate prevention
managed/services/inventory/agents.go, managed/services/inventory/agents_test.go
Agent handlers validate stored and requested types before mutation. Changes apply to the loaded row. Transactional prechecks and advisory-lock serialization prevent duplicate internal PostgreSQL QAN agents.
Protected service and agent removal
managed/services/inventory/agents.go, managed/services/management/*
Agent and service removal checks the internal PostgreSQL QAN restriction before deletion. Tests verify the service remains persisted when removal is rejected.

Sequence Diagram(s)

sequenceDiagram
  participant AgentChangeHandler
  participant ExecuteAgentChange
  participant AgentStore
  participant AgentHelpers
  AgentChangeHandler->>ExecuteAgentChange: submit typed agent change
  ExecuteAgentChange->>AgentStore: load current agent
  AgentStore-->>ExecuteAgentChange: current agent row
  ExecuteAgentChange->>ExecuteAgentChange: validate type and environment rules
  ExecuteAgentChange->>AgentHelpers: apply change to loaded row
  AgentHelpers-->>ExecuteAgentChange: updated agent or error
  ExecuteAgentChange-->>AgentChangeHandler: commit change or roll back transaction
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main fix: rejecting Change*Agent requests that target the wrong agent type before commit.
Description check ✅ Passed The description clearly documents the ticket, problem, implementation, scope, related work, and tests. It omits the template's Feature build field and API Docs checkbox, but the changes do not appear …
Full details: Description check

Explanation

The description clearly documents the ticket, problem, implementation, scope, related work, and tests. It omits the template's Feature build field and API Docs checkbox, but the changes do not appear to alter API endpoints, so these omissions are non-critical.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@JiriCtvrtka JiriCtvrtka changed the title PMM-1519 Change QAN-PGSM fix for internal error. PMM-15191 Change QAN-PGSM fix for internal error. Aug 17, 2026
…t pmm_agent_id

checkInternalPgQANDuplicate only ran when the request's pmm_agent_id equalled
PMMServerAgentID, so naming any other already-registered pmm-agent while
targeting PMM's internal PostgreSQL Service skipped the check entirely.
CreateAgent does not require a Service's agents to share a pmm-agent, so this
was reachable through the public API and produced a second, unguarded QAN
agent for PMM's own database.
…vice too

checkInternalPgQANRemoval only ran inside AgentsService.Remove.
ManagementService.RemoveService deletes an agent's row directly via
models.RemoveAgent and never calls AgentsService.Remove, so the standard
"pmm-admin remove service" path could delete the internal PG QAN agent while
PMM_ENABLE_INTERNAL_PG_QAN was still pinned. Moved the check into
models.CheckInternalPgQANRemoval, shared by both callers.
executeAgentChange loads the agent to check its type and run
checkInternalPgQANEnvOverride, then models.ChangeAgent immediately loaded the
same row again by ID inside the same transaction. Split ChangeAgent into a
thin ID-based wrapper and ApplyAgentChange, which operates on an
already-loaded row, and had executeAgentChange call the latter with the row
it already has.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
managed/services/inventory/agents.go (2)

1961-1961: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a reform.Querier precheck signature, Number One.

The precheck callback takes *reform.TX, yet the only call site immediately narrows it to tx.Querier. A reform.Querier parameter keeps the callback usable outside a transaction and matches the repository convention.

♻️ Proposed signature change
-func (as *AgentsService) executeAgentAdd(ctx context.Context, agentType models.AgentType, params *models.CreateAgentParams, getServiceInfo bool, prechecks ...func(*reform.TX) error) (inventoryv1.Agent, error) { //nolint:ireturn,lll
+func (as *AgentsService) executeAgentAdd(ctx context.Context, agentType models.AgentType, params *models.CreateAgentParams, getServiceInfo bool, prechecks ...func(*reform.Querier) error) (inventoryv1.Agent, error) { //nolint:ireturn,lll
 	var agent inventoryv1.Agent
 
 	err := as.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error {
 		for _, precheck := range prechecks {
-			err := precheck(tx)
+			err := precheck(tx.Querier)
 			if err != nil {
 				return err
 			}
 		}

The call site then becomes:

agent, err := as.executeAgentAdd(ctx, models.QANPostgreSQLPgStatementsAgentType, params, false, func(q *reform.Querier) error {
	return checkInternalPgQANDuplicate(q, p.ServiceId)
})

As per coding guidelines, "Always accept reform.Querier parameter (works with both *reform.DB and *reform.TX)".

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@managed/services/inventory/agents.go` at line 1961, Change executeAgentAdd
precheck callbacks from *reform.TX to reform.Querier, and pass the transaction’s
Querier implementation at invocation sites so checks such as
checkInternalPgQANDuplicate accept the broader interface while preserving
existing behavior.

Source: Coding guidelines


1836-1839: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the advisory-lock wait. A stalled transaction can make concurrent requests occupy database connections indefinitely. Set a local lock_timeout, or use pg_try_advisory_xact_lock and return codes.Aborted.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@managed/services/inventory/agents.go` around lines 1836 - 1839, Update the
advisory-lock acquisition in the transaction around
internalPgQANDuplicateLockKey to avoid waiting indefinitely: set a
transaction-local lock_timeout before the existing lock query, or use
pg_try_advisory_xact_lock and return codes.Aborted when the lock is unavailable.
Preserve the current error propagation for database failures.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Nitpick comments:
In `@managed/services/inventory/agents.go`:
- Line 1961: Change executeAgentAdd precheck callbacks from *reform.TX to
reform.Querier, and pass the transaction’s Querier implementation at invocation
sites so checks such as checkInternalPgQANDuplicate accept the broader interface
while preserving existing behavior.
- Around line 1836-1839: Update the advisory-lock acquisition in the transaction
around internalPgQANDuplicateLockKey to avoid waiting indefinitely: set a
transaction-local lock_timeout before the existing lock query, or use
pg_try_advisory_xact_lock and return codes.Aborted when the lock is unavailable.
Preserve the current error propagation for database failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: db584e0e-c7b2-4377-b7bd-2a3c587e6311

📥 Commits

Reviewing files that changed from the base of the PR and between b1d6874 and 2637fdd.

📒 Files selected for processing (5)
  • managed/models/agent_helpers.go
  • managed/services/inventory/agents.go
  • managed/services/inventory/agents_test.go
  • managed/services/management/service.go
  • managed/services/management/service_test.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • percona/pmm-qa (manual)
  • percona/pmm (manual)

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.

JiriCtvrtka and others added 4 commits August 21, 2026 11:57
ApplyAgentChange carries over the same branchy per-field-type update logic
that ChangeAgent already had, which was already exempted from cyclop and
maintidx; add gocognit to the same nolint directive rather than fighting the
inherited complexity.
Comment thread managed/services/inventory/agents.go Outdated

@ademidoff ademidoff left a comment

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.

Reviewed the whole diff. The two ticket asks are met and the reported 500 is genuinely fixed, but I found two ways the new removal guard can be walked around, one behaviour regression on our own default compose environment, and a fair amount of scope beyond PMM-15191.

Scope vs. the ticket. PMM-15191 asked for a small fix: run the guard before the change commits, and parse PMM_ENABLE_INTERNAL_PG_QAN as a boolean instead of "present and non-empty". Both are done. What I'd argue is also justified here: IsInternalPgQANAgent / FindInternalPgQANAgent (the old lookup genuinely over-rejected RDS/Azure QAN agents that hang off PMM Server's pmm-agent) and moving the guard inside the transaction.

What I'd like to see split out into its own ticket: the removal guard (it's a user-visible restriction on the default .env, and incomplete — see the comment on CheckInternalPgQANRemoval), the duplicate guard + advisory lock + concurrency test, and the ChangeAgent/ApplyAgentChange split, whose stated benefit isn't actually taken at the one other caller that could take it. That leaves a ~2-file PR that closes the ticket.

One correction for the PR description. It credits the guard rework with fixing the reported error, but the old guard returned FailedPrecondition, which runtime.HTTPStatusFromCode maps to 400 — it could never produce "Internal server error." The thing that actually fixes the reported 500 is the new expectedType precheck. Worth saying so explicitly, since that's the part that must not get dropped if the rest is split off.

Requesting changes on the two bypass paths and the default-deployment regression; the rest are take-or-leave.

Comment thread managed/models/agent_helpers.go Outdated
// The Agent type and the pmm-agent it runs under are not enough to tell it apart: remote PostgreSQL
// instances added through RDS or Azure discovery get their QAN Agent attached to PMM Server's
// pmm-agent as well, so the Service has to be part of the check.
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.

Comment thread managed/models/agent_helpers.go Outdated
Comment thread managed/services/inventory/agents.go Outdated
Comment thread managed/services/inventory/agents.go Outdated
Comment thread managed/services/inventory/agents.go Outdated
}

// Remove removes Agent, and sends state update to pmm-agent, or kicks it.
func (as *AgentsService) Remove(ctx context.Context, id string, force 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 makes every agent removal load and decrypt the row twice.

models.RemoveAgent opens with FindAgentByID for the same ID (managed/models/agent_helpers.go:1558), so we now pay two Reloads and two DecryptAgent passes over the row's credentials on every removal.

Moving CheckInternalPgQANRemoval inside models.RemoveAgent fixes this and the ServicesService.Remove bypass together.

Comment thread managed/services/inventory/agents.go Outdated
Comment thread managed/services/management/service_test.go Outdated
Comment thread managed/services/inventory/agents.go Outdated
Per review comment #5703 (comment):
pg_advisory_xact_lock/hashtext were the only use of Postgres-specific
advisory-lock syntax in the codebase. Row locking via SELECT ... FOR UPDATE
is standard SQL, already used the same way in role_helpers.go, and gives
the same serialization: the second transaction blocks on the locked
Service row until the first commits or rolls back, then re-reads the
settled state.

Verified RejectConcurrentAdditionOfASecondInternalQANAgent still passes
consistently under -race across repeated runs.
…hoke point

Per ademidoff's review (#5703 (comment)
and #discussion_r3880186878): ServicesService.Remove(force=true) deletes Agent
rows via models.RemoveService's cascade, which calls models.RemoveAgent
directly -- never through AgentsService.Remove. That left the guard bypassable
on a default install (.env.example ships PMM_ENABLE_INTERNAL_PG_QAN=1) via
DELETE /v1/inventory/services/{id}?force=true.

Moved the check into RemoveAgent itself, the one place every deletion path
(AgentsService.Remove, ManagementService.RemoveService, ServicesService.Remove,
node/service cascades) already funnels through, instead of guarding each
call site individually. Removed the now-redundant guard calls and the
FindAgentByID double-fetch they required at the two call sites that had them.

Added RejectRemovingThePinnedInternalPgQANAgentEvenWithForce in
services_test.go, covering the exact bypass path. Verified it fails without
the RemoveAgent-level guard (confirmed the DELETE actually commits) and
passes with it.
@ademidoff

Copy link
Copy Markdown
Member

@JiriCtvrtka The most important takeaway from my review is the following: "That should have been a ~2-file PR that'd close the ticket" :)

…_agent_id conjunct

Per review comment #5703 (comment):
IsInternalPgQANAgent required agent_type + pmm_agent_id == PMMServerAgentID +
Service name, while FindInternalPgQANAgent (what the settings API actually
acts on) only checked Service name + type. Service names are unique, so the
Service-name check alone already excludes RDS/Azure QAN agents attached to
PMM Server's own pmm-agent -- the pmm_agent_id conjunct was both redundant
and actively wrong, since PMMServerAgentID is a mutable process global
(reassigned in HA setup and from the pmm-agent config file). Requiring it
to match let the Service's QAN agent evade every guard the moment it ran
under a different, still valid, pmm-agent, while FindInternalPgQANAgent
would still find and act on it.

Added RejectRemovingTheInternalAgentEvenUnderAnotherPMMAgent, exercising the
exact exploit chain from the review: remove the pinned agent while unset,
register a second pmm-agent, re-add the QAN agent for pmm-server-postgresql
under it, then pin the variable. Verified it fails without this fix
(confirmed the DELETE actually commits) and passes with it.
… check

Per review comment #5703 (comment):
plain FOR UPDATE conflicts with the FOR KEY SHARE lock that an unrelated
agent insert takes on the Service row via agents_service_id_fkey, so it
would needlessly block adding other agents to PMM's internal PostgreSQL
Service while this check runs. FOR NO KEY UPDATE serializes concurrent
calls to this check against each other without that side effect.

Also drop the empty-serviceID early return per
#5703 (comment): service_id
is a required, non-empty field on AddQANPostgreSQLPgStatementsAgentParams,
so the only caller can never reach it empty.
@JiriCtvrtka

Copy link
Copy Markdown
Contributor Author

@JiriCtvrtka The most important takeaway from my review is the following: "That should have been a ~2-file PR that'd close the ticket" :)

Fair — though "2 files" only holds if we'd literally scoped to the ticket wording. The expectedType precheck is what actually fixes the 500, and it can't be smaller than what's in the PR now: it's one bug reachable from all 16 Change*Agent methods, so closing it means touching every one of those call sites regardless. The removal/duplicate guards are the separate scope-add you're flagging — happy to split those into their own ticket if you'd rather review them apart from the fix itself.

@ademidoff

Copy link
Copy Markdown
Member

Agreed on the precheck — that one's yours, and I said as much in the inline comment on managed/services/inventory/agents.go: it's the part that must survive if anything gets split. models.ChangeAgentParams carries no type discriminator, so passing the expected type means editing all 16 Change*Agent call sites however you shape it. No argument.

It's also worth a line in the ticket. Both triage comments prescribed "move the guard before executeAgentChange + parse the bool" and then left the 400-vs-500 mismatch open — the guard returns FailedPrecondition → 400, so it was never the reported 500. Your precheck is the answer to that open question, and the triage's recommended fix on its own would not have closed the ticket.

So yes, let's take your split offer for the removal and duplicate guards. Two reasons, neither of them line count:

  1. CheckInternalPgQANRemoval is a behaviour change on default installs. .env.example:12 and .env.dev.example:12 both ship PMM_ENABLE_INTERNAL_PG_QAN=1, so pmm-admin remove service pmm-server-postgresql starts returning FailedPrecondition for everyone who uses our compose file. documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/env_var.md:49 still describes the variable as a default-only toggle. That needs a doc update and a release-note entry, which a bug ticket won't get it.

  2. It's bypassable as written. inventory.ServicesService.Remove(force=true) goes straight to models.RemoveService(tx.Querier, id, models.RemoveCascade) at managed/services/inventory/services.go:325, cascades into models.RemoveAgent, and deletes the pinned agent. This PR doesn't touch that file. A new restriction that's both undocumented and removable in one API call is worse to ship than to hold — and in its own ticket it gets room for the models.RemoveAgent choke point, which covers inventory Remove, ManagementService.RemoveService and both cascades at once.

That leaves the env-override rework in this PR, which I'm not asking you to drop — it's the code the ticket actually points at.

Per review: both are separate concerns from the internal error this
ticket reports, and each needs room the bug ticket won't give it.

CheckInternalPgQANRemoval changes behaviour on default installs --
.env.example and .env.dev.example both ship PMM_ENABLE_INTERNAL_PG_QAN=1,
so `pmm-admin remove service pmm-server-postgresql` would start
returning FailedPrecondition for every compose-file user, and env_var.md
still describes the variable as a default-only toggle. That needs a doc
update and a release note.

checkInternalPgQANDuplicate is a new restriction on adding an agent,
independent of the reported error.

What stays here is the code the ticket points at: the agent-type precheck
in executeAgentChange and the PMM_ENABLE_INTERNAL_PG_QAN override rework
(env.LookupBool, checkInternalPgQANEnvOverride, the service-aware
internal-agent lookup).
handleInternalQANToggle called models.ChangeAgent on the row
getInternalPgQANAgent had just loaded, so every ChangeSettings call that
toggled enable_internal_pg_qan re-ran FindServiceByName + FindAgents and a
second Reload/DecryptAgent pass. It now passes the loaded row to
ApplyAgentChange, which is the caller that motivated splitting ChangeAgent
in the first place.

Also drop the nil check below it, which cannot fire (getInternalPgQANAgent
returns a non-nil agent or an error, and FindInternalPgQANAgent never
returns (nil, nil)), and drop the inner error wrap: both callers add their
own context, so the message read "failed to get QAN agent: failed to find
internal pgQAN agent: ...".

Use %s rather than %q for agent IDs, agent types and the environment
variable name, matching FindAgentByID's "Agent with ID %s not found." and
RemoveAgent's "pmm-agent with ID %s has agents." The unparsable value
itself keeps %q, where quoting distinguishes an empty string.
JiriCtvrtka and others added 3 commits September 1, 2026 14:07
…ents

FindInternalPgQANAgent went through FindAgents, which re-validates a
ServiceID filter with FindServiceByID -- a third round trip for the Service
row it had just loaded. It now queries the agents table directly, the way
FindPMMAgentsForService does. This is on GetSettings, which the UI polls.

checkInternalPgQANEnvOverride read PMM_ENABLE_INTERNAL_PG_QAN only after
IsInternalPgQANAgent, so a request that flips a pg_statements agent paid a
Service lookup even with the variable unset, which is the default. The
variable is now read first, and an unset variable short-circuits before any
query. The unparsable-value rejection still applies only to the internal
agent, so behaviour is unchanged.

Server.getInternalPgQANAgent had become a pure pass-through to
models.FindInternalPgQANAgent with a comment explaining that it adds
nothing; inlined at both call sites.

unsetInternalPgQANEnv is now tests.UnsetEnv, next to SetTestIDReader, since
the same idiom was about to exist in three packages.

Comment trims where the prose restated the code or repeated a fact stated
elsewhere, and two additions where the reason was genuinely missing: why
checkInternalPgQANEnvOverride must NOT move into models the way
CheckInternalPgQANRemoval did (handleInternalQANToggle is the legitimate
actor for that state and would trip over its own pin), and that expectedType
has to stay in sync with the caller's type assertion.

Also reverts a pointless `var err error` move in AgentsService.Remove.
@JiriCtvrtka JiriCtvrtka changed the title PMM-15191 Change QAN-PGSM fix for internal error. PMM-15191 Reject a Change*Agent request for the wrong agent type before it commits. Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants