Skip to content

PMM-15326: OpenManager initial implementation - #1371

Draft
plebioda wants to merge 23 commits into
pmmfrom
PMM-15326-pom-inventory
Draft

PMM-15326: OpenManager initial implementation#1371
plebioda wants to merge 23 commits into
pmmfrom
PMM-15326-pom-inventory

Conversation

@plebioda

Copy link
Copy Markdown
Collaborator

Summary

Tested

Checklist

  • New/modified functions have type hints and rST docstrings
  • New tests added for new features or bug fixes
  • All tests pass locally (make test)
  • Pre-commit hooks pass (make run-pre-commit)
  • Database migrations generated if models changed (make makemigrations)
  • User-facing changes documented (README, inline help, UI text)
  • Configuration changes documented with examples
  • Changelog fragment added under changelog.d/ if the change is user-facing (make changelog-add), or confirmed N/A (internal-only change, or a same-release-cycle fix for an unreleased sibling ticket)

POM's read path lives in pmm-managed, which derives the topology document from PMM's
inventory and VictoriaMetrics. What belongs in SEP is the half that needs a process
on a database host, and this app is that half.

pom_discovery sweeps the estate over Nomad on its own schedule: list the MongoDB
services from SEP's inventory, resolve each strictly to the executor host its probe
must run on, dispatch one payload per host, record what came back. An unmatched
service is recorded as orphaned rather than probed somewhere wrong, because probing
the wrong host reports confidently wrong facts about it.

It collects what no metric carries -- the installed binary version above all, whose
divergence from the running one is the upgraded-but-not-restarted case. GET
/api/apps/pom_discovery/facts serves them for PMM to pull. That endpoint never
probes: it answers from the last completed sweep and says how old it is, which keeps
the consumer's collection sub-second against a source whose own work takes tens of
seconds.

A run records what it saw service by service, not four counters: executor host, how
it was matched, whether it answered, the host's wall-clock, its fact count, its
error. The duration is measured around the whole dispatch rather than read off the
task history, because a host queued behind a busy client costs the sweep as much as a
slow payload. It is monotonic and taken in a finally, so a host that failed instantly
is still timed -- 0.28s says the dispatch was refused, which is a different fault
from a host that took a minute.

A dispatch that fails after reaching the queue is stopped. Otherwise the queue item
stays RUNNING, the tasks API refuses an identical item, and every later sweep to that
host answers 409 while reporting itself partial -- which reads as an unreachable node
rather than a queue needing a clear. The release is best-effort, because it
legitimately fails when the allocation is already gone; the reason is folded into the
host's error, naming the id that will block the next probe.

host.docker.internal joins ALLOWED_HOSTS: pmm-managed reaches this app from inside
the pmm-server container, where the host is only addressable by that name.
settings.embedded.yaml's SEP.APPS is what image-sidecar-embedded strips the app set
down to, so without an entry the app is absent from the image however the rest is
configured -- restrict_apps.py deletes the package, and because the restricted image
can only narrow the list, no environment variable or bind mount brings it back.

POM is split across both products, so a PMM shipping this side-car also needs a build
carrying managed/services/pom. That is unlike atw, whose PMM half is already on the
branch.
Agreed on 2026-08-12: POM's SEP-side tables live in the existing `sep`
database, isolated by a dedicated PostgreSQL schema. Not a separate database,
which would make every call site choose a connection and need a second
migration mechanism; not a table-name prefix, which isolates nothing.

No behaviour change. This is the cheapest first step and it de-risks the rest:
the tables that follow arrive into a schema that already works.

The mechanism is SQLAlchemy's *symbolic* schema, copied from what SEP already
does for the Celery beat tables. Models declare the token `pom_schema` and the
engine translates it per bind -- to `pom` on PostgreSQL, to the default schema
anywhere else. One set of table definitions therefore works on a deployment
that wants the isolation and on SQLite, which has no schemas at all, with no
branching in the models.

app/sep/pom/ is new and holds what every POM app shares rather than what this
one owns: discovery is the first app, and restart, configuration change,
upgrade and installation are meant to follow into the same schema. An app that
later ships without the others must not take the schema definition with it.
Nothing in it touches sep_settings at import time -- the bind is passed in by
the caller, because reaching for the lazy settings proxy mid-construction is
what the Celery config already carries a warning about.

Resolved to the default schema on everything except PostgreSQL. SQLite has no
schemas short of an ATTACH, and MySQL's "schema" *is* a database, so honouring
the setting there would scatter POM's tables into a second database nothing
provisions.

The table is `discovery_run`, not `pom_discovery_run`. The prefix was the only
thing saying what the table was while it sat in the default schema; inside
`pom` it is stutter.

The migration is rewritten in place rather than followed by a move migration,
because nothing has shipped -- there is no deployment whose data would be
preserved. The cost is local and worth stating: a machine that already ran the
old revision has to drop the old default-schema `pom_discovery_run` and delete
the `a3f1c8d24b71` row from `alembic_version_sep` by hand, since the migration
that would have moved it deliberately does not exist. Once POM ships, that file
freezes and changes become new revisions.

Three things in it are easy to get wrong:

- CREATE SCHEMA cannot be symbolic. Raw DDL is not translated, so the migration
  asks pom_schema() for the real name and skips the statement where the bind
  has no schemas. Everything after it stays symbolic.
- The version table stays `alembic_version_sep` in the default schema. Splitting
  head bookkeeping for a shared target across two schemas would be worse than
  the isolation is worth, and every other app on that target lives there.
- Do not autogenerate these. Alembic applies no schema_translate_map during
  autogenerate and include_schemas is off, so --autogenerate proposes creating a
  literal `pom_schema` and re-creating the tables on every run. Treat an
  autogenerated POM diff as a bug.

The test harness needed one change, and it is not the obvious one. This suite
creates every service's metadata in a single in-memory SQLite database, so a
POM table called `service` would be SEP inventory's `service` -- production
never collides, since PostgreSQL separates by schema and SQLite keeps the two
services in separate database files. Rather than translate the token in each of
the ~30 places the suite builds an engine, the root conftest attaches an
in-memory database under the token's own name: SQLite has no schemas but it has
ATTACH, and an attached database is a schema as far as SQL is concerned. So
`pom_schema.service` resolves untranslated and no fixture needs to know. The
PostgreSQL and MySQL fixtures do still map it, into their per-worker schema,
because neither has an ATTACH to lean on.

No PMM-side provisioning change: postgres-sep already creates the `sep` role
and database, the migration runs as that role, and the role therefore owns the
schema it creates. Grants only become work if a future POM process connects as
its own role.
Agreed on 2026-08-13: POM's inventory covers both host and service. A host may
carry no service; a host may carry more than one, ideally one.

Two tables, upserted, each a few real columns for identity and freshness plus
one JSONB document of everything probed. Changing what is collected is then a
payload change rather than a migration -- this is the start of the project and
the attribute set will move more than once. The cost is that fleet-wide
questions become JSONB queries; promoting a field to a column later is easy and
the reverse is not.

Why a host is an entity rather than an attribute of a service: it has state of
its own, roughly half of what is collected belongs to it, it is what a future
install or restart targets, and -- the decisive part -- a host with no database
has to be representable. Against a service-keyed table it simply is not there.

That case is measured, not assumed. This sandbox runs three hosts carrying a
PMM client and nothing else, beside two arbiters running a mongod PMM has no
service for, and **PMM's inventory describes the two identically**: same node
type, zero services, the same four agents, the same null distro. Two entirely
different situations -- "there is a database here PMM cannot authenticate
against" and "there is no database here at all" -- and no field tells them
apart. Only probing the host does, which is what the host document is for.

Keyed on PMM's own ids, node_id and service_id, so the tables and the API speak
the ids every consumer already holds and nothing needs translating anywhere,
including a scoped refresh triggered with the node_id PMM has. Stored as text,
not uuid: PMM's ids are usually UUIDs but not always -- the PMM server's own
node is the literal string `pmm-server` in every deployment, and a uuid column
would reject the one node every installation has.

No POM-minted surrogate key. It only pays off if POM can recognise a
re-registered machine and keep one row, and there is no natural key to do it
with: `machine_id` is inherited from the container image, so most of this
sandbox reports one shared value and the rest report an empty string. Matching
on it would merge unrelated hosts. The consequence to accept is that a
re-registered node gets a second row until retention prunes it.

Enumeration changes source. The app listed services and could therefore only
ever produce hosts that run a database; hosts now come from SEP's inventory
nodes -- filled by PMMSyncer independently of services -- crossed with the Nomad
executor list. A node with neither a MongoDB service nor an executor is some
other machine PMM monitors and is left out; PMM's own server node is one.
Executor matching reuses mapping.py's name-then-address order at the host
level, where it belongs, and keeps its refusal to fall back to an arbitrary
host: that would mean probing one machine and recording the answers against
another.

The freshness lifecycle is where the care went, because every rule reads as an
implementation detail and none is. failing_since is set with COALESCE, so it
keeps saying "failing for three days" rather than "failed a minute ago". A
failed probe does not erase `observed` or `role` -- what a host was running when
it was last reachable is exactly what is wanted while it is not. And an entity a
run did not target keeps its columns untouched, which is what makes a scoped
refresh possible later without marking the rest of the estate failed.

That last rule has two teeth, both found against live data rather than reasoned
out:

- An orphaned service still gets a row. It is a service PMM knows about, and
  hiding it reports a healthier estate than exists -- the PoC measured 17 of 18
  services unreachable in one run. What it does not get is an *attempt*.
- A host with an executor and no service is seen every sweep and probed by
  none, because dispatch is driven by targets. Counting that as a failed attempt
  would have exactly the pmm-client hosts accrue a failure every ten minutes,
  forever, for a condition that is not a failure.

The upsert is written column-explicitly. Nothing here is user-writable yet, so
there is nothing to clobber -- but a blanket "update every column" wipes the
first field that ever is, and the test for it cannot be added after the fact
because by then the data is gone. It stands in on first_seen_at, the one column
no attempt may move.

pom.service.node_id is a real foreign key, so hosts are written first in the
same transaction. Across apps the pom schema takes no foreign keys at all: each
app's migrations are an independent branch, an image that strips an app removes
its versions/ directory, and there is no ordering between branches, so a
cross-app FK can reference a table that legitimately vanishes.

Verified against the live sandbox: 17 host rows (4 with executors) and 14
service rows, of which the one running database is probed and carries
installed_version 7.0.39-21; the three pmm-client hosts are seen with their
executors recorded, no attempt and no failures; the 13 unreachable services are
rows with their freshness columns untouched.

Two things this deliberately does not do yet. Mongods PMM has not registered are
scoped out as rows and will be recorded on the host's document, which needs the
payload to report processes outside its target list. And a host with no database
gets an empty document, because dispatch still needs a target -- probing a host
for its own sake is the next step, and it is what fills in the OS, package
manager and repo reachability the install case will want.
Dispatch was driven strictly by resolved services, so the machines POM could say
the least about were exactly the ones an install decision is about: a host
carrying a PMM client and no database has no service to be reached through. Its
row existed and its `observed` document was empty, permanently.

Three changes, one idea.

The payload now prints a record for the *host* -- OS, kernel, the installed
binary -- before any target records, and does so whether or not there are
targets. It used to exit 1 on an empty target list, treating "nothing to probe
here" as a misconfiguration when it is a legitimate and interesting state: it is
what a machine looks like before anything is installed on it.

The consumer tells the two apart by `service`: null on the host's record, always
a name on a service's. That is the whole discriminator, and it means the host
line cannot be mistaken for a service however a service is named.

probe_all takes the executor hosts of the whole estate as well as those serving
a resolved service, and dispatches to every one of them -- with that host's
services as targets where it has any, and an empty target list where it does
not.

The host's document now comes from the host's own record rather than from
whichever service record happened to answer. That was always a workaround: the
attributes are collected once per dispatch and belong to the host, so reading
them off a service was fine only while every host had a service to read them
off. It stops being fine the moment a host has none.

A failed dispatch that produced only the host record is no longer counted as a
failure. On a host with no database that record is the *only* thing the dispatch
had to produce, so treating "no service records" as failure would fail every
empty host by construction.

Verified against the sandbox: the three pmm-client hosts now carry
`{"os": "Ubuntu 24.04.3 LTS", "kernel": "6.17.0-35-generic", "collected_at": ...}`
and count as probed, where before they were seen with an empty document. 17 host
rows, 4 probed -- the four with a live executor -- and 14 service rows unchanged.

This is also the precondition for the two things that follow: recording mongods
PMM has not registered (the arbiter case) needs the payload to report processes
outside its target list, and the rest of the §11 attribute set -- package
manager, free space, repo reachability -- is per-host and now has a record to
land in.
A service row is a service PMM knows -- that is what keying on `service_id`
means. But the probe finds mongod processes PMM has no service for, and dropping
them let the estate view call a host empty while a database ran on it.

They go on the **host's** `observed` document as `unregistered_mongods`, each
with its program, pid, port, config path and command line. No identity to
invent, no schema commitment, and the information survives. Promoting them to
rows is the change whenever the discovery work needs it, and it needs a key --
which is where `(node_id, port)` comes back.

The cause is worth knowing before anyone decides this is rare. An arbiter holds
no data, therefore no user documents, therefore SCRAM cannot authenticate,
therefore `pmm-admin add mongodb` fails for it -- so **any** estate with
arbiters and authentication enabled has mongods PMM has no service for. This
sandbox has two, and neither has ever been in PMM's service list.

The payload gains three pieces. `collect_server_processes` reports every mongod
and mongos, where `collect_process_facts` deliberately reports only the first --
a probe of one service wants one answer, and "what is actually running here"
wants all of them. `parse_port` reads the port from the command line, falling
back to the configuration file, because every node in this sandbox is started as
`mongod --config <file>` with the port set inside it; an argv-only reading would
find nothing on any of them and report every registered service as a stranger.
`find_unregistered` subtracts the targets by port.

A process whose port cannot be determined is reported rather than dropped. It
cannot be matched to a target, and silently discarding a running database is
exactly the dishonesty this list exists to prevent -- better a visible entry with
a null port. A target with no port registers nothing, for the same reason:
treating null as a wildcard would hide every stranger on the host.

Verified against the sandbox with the sharded cluster running. Both arbiters
report their mongod on 27018, PMM has no service for either, and every host
whose services are registered reports none.

Two things this turned up, neither of them new:

- **Application changes need the backend restarted; payload changes do not.**
  The payload is read off disk per dispatch, so an edit reaches the next sweep;
  the sweep's own code is whatever the worker imported at start. This cost an
  hour of looking at a node that was reporting correctly into a sweep that was
  not reading the field.
- **Stale host rows are real.** `standalone-node00` and the pmm-client hosts each
  have two rows: PMM re-registered them under new node ids when their pmm-agents
  were restarted, and POM keeps the old row because nothing prunes. PMM itself
  has exactly one node per name. This is the consequence §5.3 accepts openly in
  exchange for using PMM's ids, and it is the concrete argument for the retention
  question §15 leaves open.
… forgotten

The tables have been filling since `9a43b115` with nothing but psql to read them.
This is the read surface, plus the two deletes.

    GET    /hosts                  every host, its services nested
    GET    /hosts/{node_id}        one host
    GET    /services               flat, for consumers that work in services
    GET    /services/{service_id}  one service
    DELETE /hosts/{node_id}        forget a host and what was on it
    DELETE /services/{service_id}  forget one service

`GET /hosts?has_service=false` is the question the host table exists to answer:
which machines carry a PMM client and no database. A filter rather than an
endpoint of its own, because the same list with the filter inverted is the
ordinary estate view and one list contract is enough to learn. `failing` and
`executor` filter the same way.

Both item paths take **PMM's** ids, which is the whole benefit of keying the
tables that way: the path is the id every consumer already holds, with no lookup
step on either side. There is deliberately no `/hosts/{id}/services` -- a host
already carries its services, so that would be a second spelling of the same
list, and `/services?node_id=` covers wanting them without the host.

DELETE is here because stale rows are not hypothetical. Restarting a node's
pmm-agent runs `setup --force`, which replaces the node in PMM and mints a new
id; POM keys on that id, so it gains a row and keeps the old one, and nothing
prunes. Today this sandbox had eleven such rows -- every one of them created by
restarts made while fixing something else. Without a delete the only cure is
psql against a schema an operator should never need to know exists.

It is not suppression. An entity PMM still knows about returns on the next
sweep, because POM's job is to describe what PMM says exists, not to hold an
opinion about it. And it is not retention: automatic pruning of entities that
vanish is still open, and this is what makes its absence survivable meanwhile.

**Deleting a host deletes its services explicitly, and that is not redundant.**
`pom.service.node_id` is `ON DELETE CASCADE`, so the DDL says the database will
do it -- but SQLite enforces no foreign key unless `PRAGMA foreign_keys=ON` is
set per connection, SEP sets it nowhere, and SQLite is the shipped default in
settings.yaml. On a default deployment the constraint is decoration, and
deleting a host would have left services pointing at a host that no longer
existed. The test caught it; reading the DDL would not have. The constraint
stays as the backstop it genuinely is on PostgreSQL.

Verified against the live sandbox, on the actual duplicates rather than
fixtures: `DELETE` on a stale `pmm-client-node00` row returned 204, a second
returned 404, and the host was left with exactly one row. Then every host row
whose node id PMM no longer knows was forgotten through the API -- eleven of
them -- leaving 18 hosts, 12 services and no duplicates. That is precisely what
retention will need to do automatically, done by hand to prove the surface can.

Three test failures that had been standing since the app was written go with
this, because they were never about this change:

- `tests/app/sep/snapshots/{openapi,schema}/pom_discovery.json` did not exist.
  The app was added without generating them, so four snapshot tests failed on
  every run. `make regen-specs` writes them; they now cover the new routes too.
- `test_contract.py::test_list_200` asserted a `GET /` list route this app has
  never had, having been written for the task-contract scaffold rather than a
  declared router. Removed rather than repaired: `test_schema_200` already proves
  the router is mounted behind the auth guard, without needing a database, and
  what the routes serve is pinned against a real session in the new tests.

The app's suite is green: 77 passed, none failing.
`POST /runs` takes `{"node_ids": [...]}`. Absent or empty still means everything,
which is what the scheduled sweep passes.

The two questions are different sizes. "What does the estate look like" is a
Nomad job per executor host: measured at 90-101 seconds across this sandbox's
fifteen. "I just did something to this host, is it healthy now" is one job, and
measured at 44 seconds - it should not have to wait on the other fourteen, and it
is the question every action PMM grows will ask next. The scope is node ids,
which PMM already holds, so its trigger passes them through untranslated.

**Conflict is judged per host.** The old guard refused any run while any other
was in flight, which with a ten-minute schedule means a scoped refresh is
usually refused exactly when someone wants one. Two runs now collide only when
they would touch the same host; a full refresh collides with everything,
including another full refresh. The stale-run reaper stays, because a crashed
worker still leaves a row nothing else advances - one was sitting at 900 seconds
while this was being written.

**Nothing outside the scope is written.** The narrowing happens in one place,
after enumeration and before any dispatch, so §5.4's "only a run that attempted
an entity touches its timestamps" stops being a principle and starts being the
thing that keeps a one-host refresh from marking the estate failed. An id that
names no enumerated host narrows to nothing rather than to everything, which is
the failure worth being paranoid about.

Three bugs found by building it, two of them older than this change:

- **A full-estate run stored the JSON scalar `null`, not SQL NULL.** SQLAlchemy's
  JSON types default to `none_as_null=False`, so `WHERE scope IS NULL` matched no
  full sweeps at all. The Python side reads back `None` either way, so nothing
  complains until someone asks the database a question about scope. Exactly the
  trap `bugs/sep-autojson-drops-none-as-null.md` records, walked into while
  hand-rolling a Column specifically to avoid it. There is a test that asserts
  against SQL rather than the response, because the response cannot tell.

- **Aging a run raised `TypeError` on SQLite.** `utc_now() - run.started_at`
  subtracts an aware datetime from a naive one, because SQLite stores no timezone
  - and SQLite is the shipped default. The stale-run guard would have 500'd on
  precisely the request meant to recover from a crashed worker. Pre-existing;
  no test had ever put a RUNNING row in front of the trigger. `get_facts` aged
  the same way and is fixed with it.

- **A run that probed a host and no services reported FAILED.** `_terminal_status`
  judged on services alone, which was right while every dispatch existed to reach
  one. It stopped being right when a host became probeable for its own sake: a
  scoped refresh of a pmm-client host resolves no services, because there are
  none, and was reported as a failure for doing exactly what it was asked. It now
  counts dispatches and services together. Measured on standalone-node00 before
  and after: FAILED, then SUCCESS, same work.

Verified against the sandbox: a scoped refresh of a host with no database
completes in 44 seconds and reports SUCCESS, beside full sweeps of 90 and 101
seconds; conflicts answer 409 naming the run that holds the host.

Worth recording for whoever optimises next: the 44 seconds is almost entirely the
run-python job template building a venv and pip-installing pymongo on the host
before the payload runs. The probe itself is milliseconds. Scoping avoids paying
that fifteen times; not paying it at all is a bigger win still.
The estate replaces it. `/facts` answered "what did the last completed sweep
collect", which is a question about a run; `/services` answers "what is on this
service", which is the question the consumer was always asking. The difference is
only visible when a sweep fails: `/facts` dropped a service the newest sweep could
not reach, so PMM lost every probe field for it, while an upserted row keeps
reporting what it last saw and how old that is.

Nothing shipped this, so there is no compatibility window to keep. pmm-managed moved
to `/services` in the same change (pmm `5599e4876`), and `./om discovery show|facts`
with it - `facts` now reads the estate through the API, which makes a disagreement
between it and `./om discovery estate`, which reads the tables, a serialisation bug
worth finding rather than two views of different things.

`latest_terminal_run` goes with the endpoint: its whole reason for existing was
skipping a sweep in flight so the merge would not see an empty fact set for the
duration, and rows do not empty out while a run is going. `FACTS_MAX_AGE` goes too -
staleness is now per row, carried on the row, and there is nothing left to judge it
against globally. `GET /runs/{id}` keeps serving the run's own fact list, which is
still the honest record of what one sweep did.
@plebioda
plebioda force-pushed the PMM-15326-pom-inventory branch from 860d724 to ff40b01 Compare August 19, 2026 22:57
…entation

REVIEW NOTE: this commit is deliberately on its own because it touches shared
authorization and configuration plumbing. **It needs sign-off from the PMM and SEP
teams before the app on top of it is merged.** The open question is written up in
PMM-15326's plan, §10; the short version is below.

**The question.** SEP's settings API (`/api/sep/admin/settings`) requires an admin.
PMM talks to SEP with a shared deployment token that resolves to the `sep-service`
account, and that account is not an admin - it is constructed with `is_admin` left at
its default (`app/api/deps.py:49`), so PMM gets a 403 from that router. Either the
account becomes an admin, or an app that PMM must configure serves its own endpoint.

**What this enables, and why.** The second option. Making the shared token an admin
would mean a leaked `SECRET_KEY` grants administrative access to all of SEP rather
than to one app's settings, which is a much larger blast radius than the problem
needs. A reviewer who disagrees should say so here rather than after the UI is built
on it.

**What actually changes.** Nothing about who can reach the existing router: it keeps
its admin gate, and no caller gains a permission. The local-class bodies of
`PATCH /{setting_class}` and `DELETE /{setting_class}/{key}` move out to
`apply_class_overrides` and `clear_class_override` so an app-owned endpoint can call
them instead of carrying its own copy. Two copies of "validate the batch, write it
atomically, republish the snapshot, rebind" drift into two different sets of
validation rules, and that is the failure this refactor exists to prevent. The
handlers now resolve the class, branch on remote, and delegate; the 353 tests over
this package pass unmodified.

`clear_class_override` is exported for a reason and not for symmetry: without a
revert, an app's own endpoint can set a value but never unset one, so "no override"
stops being reachable and the YAML the deployment ships becomes unrecoverable through
the API.

Also here, because both are shared files rather than app files:

- `POM_DISCOVERY_SETTINGS` joins `SettingClassEnum`, with the CHECK-constraint
  migration on all three tracks. The member list is kept converged even though the
  class is app-owned and collected only on the SEP side, so its rows only ever land
  in the SEP database - the same reasoning that put `ALERTS_SETTINGS` and
  `INVENTORY_SETTINGS` on tracks that never write them. Diverging constraints would
  make "which members are allowed" a question about which database you are looking at.

- The web process's rebind-callback registry moves out of the lifespan into
  `build_sep_override_callbacks`, and gains the entry that re-seeds beat when POM's
  schedule changes. Every entry in that map is a change with an effect *outside* the
  settings snapshot - a client rebound, a logging config re-applied, a beat row
  rewritten - and a missing one is invisible from the handler code: the setting
  changes, the API reports the new value, and nothing acts on it. Extracting it is
  what makes the map assertable, which the next commit's tests rely on.
`SCHEDULE` is the setting that matters: nothing else refreshes the estate, so "how
often does POM look" is an operational question, and answering it today means editing
a file and restarting. `PomDiscoverySettings` becomes an overridable app-owned class
with `GET` / `PATCH` / `DELETE /config` on the app itself.

Builds on the previous commit, which carries the shared plumbing and the open
authorization question this depends on. In short: the app serves its own config
endpoint because SEP's settings router requires an admin and PMM's principal is not
one. **That decision is not settled - see the previous commit and the plan's §10.**

**`CREDENTIALS_PATH` is the one field that stays YAML/env only.** It names a file the
payload reads on every database *host* and hands to a driver as a URI, so making it
settable over the API would turn "configure this app" into "read a chosen file across
the estate" - a different permission from the one being argued for. Everything else
is hot.

Two registration steps beyond the obvious ones, both of which fail *silently*. The
endpoint answers correctly without either, which is why each has a test:

- **`APP_OWNED_SETTINGS_CLASSES` has to be re-exported from the package `__init__`.**
  The collector reads it off the app package, so a declaration living only in
  `app_owned_settings.py` is never found. Every request still works - `GET` reads
  rows straight from the database, `PATCH` republishes the snapshot inline - and then
  the refresher never republishes, so an override silently reverts to YAML on
  restart. Measured: a 25-minute sweep back at 10 after `./om restart sep-backend`.

- **`SCHEDULE` has to be wired to the beat re-seed callback.** The proxy holding a new
  interval is not the same as beat running on one; beat reads `celery_periodictask`,
  which only changes when that callback fires. Without it the API reports a changed
  schedule and the sweep keeps firing on the old one, which is the worst shape a
  configuration bug can take. The entry itself is in the previous commit, since it
  lives in shared wiring.

Turning the sweep off works because the `SCHEDULE is None` check sits inside the
`periodic_task_schedules` thunk rather than guarding a list at import: `null`
unregisters the task and an interval re-registers it. That was already true and is
now documented, since a literal would have decided once and stayed decided.

Verified end to end against the sandbox: `PATCH {"SCHEDULE__every": 25}` put 25 on the
`celery_intervalschedule` row `sep__run_pom_probe` points at with nothing restarted,
`DELETE` put it back to 10, and both survived a restart in the right direction.

Worth knowing before building the UI: `GET` and `PATCH` do not share a key set. The
LIST projection expands a nested model into leaves, so `GET` returns `SCHEDULE__every`
and `SCHEDULE__period` and never `SCHEDULE`, while `PATCH` accepts either - and only
the parent form can express `null`. A form built from the GET key set cannot discover
the one key it needs to turn the sweep off.

Three existing tests change, all of them closed-set assertions over the wired settings
classes: a new app-owned class is exactly what they exist to notice. The ordering
assertion in `test_settings_proxy` is rewritten to compare positions rather than fixed
negative indices, so the next app to declare a class does not break it again.
Generated output, in its own commit so the feature diffs stay readable.

`tests/app/test_openapi_specs_fresh.py` has been failing on this branch for
several commits: every POM endpoint added since the app landed is missing from
`frontend/packages/api/specs/sep.json`, which is what the frontend codegen reads.
Nine paths appear here and none disappear, all of them `pom_discovery`'s -
`/hosts`, `/services`, `/runs`, `/schema` from earlier commits, `/config` from
this one.

`inventory.json` and `tasks.json` move by five lines each: `SettingClassEnum`
gained `PomDiscoverySettings`, and every sub-app's settings API publishes that
enum.

The rest of the churn in `sep.json` is key ordering. The generator re-serialises
the whole document, so the diff is far larger than the nine paths that actually
changed; compared as parsed JSON, nothing else moved.
`GET /hosts/` answers "where can I place a job". `NomadExecutor.get_hosts` produces
it by filtering on three conditions at once - `Status == ready`, `raw_exec` present
in `Drivers`, and its `Healthy` flag - and returning a name-to-address mapping. That
is the right answer for a dispatcher and all it needs.

It is the wrong answer for anything reporting on the fleet, because the three
conditions collapse into one bit and the failures land in the same place: absence.
A machine missing from that mapping may never have been onboarded, or be onboarded
and down, or be up with a broken driver. Those need three different people to fix
them, and a caller looking at the mapping cannot tell which it is - or even that the
machine exists.

So `GET /hosts/states/` alongside it, returning one `ExecutorHostState` per host the
backend knows about, with `reachable` and `driver_healthy` reported separately and
the driver's own `HealthDescription` carried along. Nothing about `/hosts/` changes;
no caller is moved.

`get_host_states` is concrete on `BaseExecutor` rather than abstract, defaulting to
"everything `get_hosts` returns, reachable and healthy". That is true by construction
for any backend, and it means a backend with no notion of an unusable host does not
have to say so - `CeleryExecutor` runs the work in-process and has nothing to add.
Nomad overrides it. Making it abstract would have edited every implementation and
every test double to say nothing.

Two details worth keeping:

- The unfiltered node list is fetched without `resources=True`. The stub entries
  already carry `Status` and `Drivers`, and the detail fetch is one request per node
  against a Nomad that may have hundreds.
- A missing `raw_exec` key reads as unhealthy, not as absent-so-fine. Nomad omits
  drivers it has not detected, so the never-onboarded host has no key at all -
  treating that as healthy would report the emptiest case as the best one.

The driver name and the ready status are now named constants shared by the dispatch
filter and the reporting, so the two cannot drift into disagreeing about what
"healthy" means.

Wanted by POM, which has to describe hosts it cannot probe (PMM-15326 §11), but
nothing here is POM-specific.
§11's orphan split. "Nothing can run on this machine" was one outcome and is
really three, and they need three different people to fix them:

  registered: false                        never onboarded - go and set it up
  reachable: false                         onboarded, agent down - go and start it
  driver_healthy: false                    up but broken - go and read its logs

All three arrived here as the same thing: absence from `GET /hosts/`, whose three
filter conditions collapse into one bit. A host that failed any of them was simply
not in the list, so POM could not describe it and could not say why.

Enumeration now reads `GET /hosts/states/` (previous commit) and matches nodes
against **every** known executor rather than the usable subset. Dispatch still works
from the usable ones, via `usable_executor_hosts`, so nothing is dispatched anywhere
new.

Two consequences worth stating, because both change what ends up in the estate:

- **A host does not leave the estate when its agent stops.** Scope is decided on
  whether an executor matched, not on whether it works. Deciding it on usability
  would drop a machine at exactly the moment someone starts looking for it - and for
  a host with no database, drop it with no service to bring it back.

- **`has_executor` now means *usable*, not *matched*.** It is what decides whether a
  probe is dispatched, and a matched-but-down executor answering true there produces
  a dispatch that waits out its timeout instead of a row that explains itself.

The executor facts are written on **every** sweep whether or not the host was
probed, so `upsert_host` takes them separately from `observed`. They are not probe
output - SEP knows them without running anything - and the hosts nothing can run on
are exactly the ones whose document would otherwise be empty with no explanation.
Merged after the attempt rather than before, since a successful probe replaces
`observed` wholesale and would drop them from the hosts that did answer.

Verified against the sandbox, on rows that previously read identically:

  pmm-client-node00          registered: true,  reachable: false
  replicaset-single-node00   registered: false, reachable: false

The first has a stopped container and a Nomad client that still knows about it; the
second has no client at all. One sweep, one query, two different answers.
§11's `repo.*`. Install and upgrade readiness, and the one precondition POM could
not previously report: a host may be up, probeable and running a healthy database
and still be unable to fetch a package.

An HTTPS GET of `PERCONA-PACKAGING-KEY` rather than a ping or a TCP connect, because
every failure worth catching lives above that layer. DNS that resolves nowhere
useful, a TLS interception appliance with a certificate the host does not trust, a
proxy that allows CONNECT but blocks this origin, a captive portal serving its own
page - all four pass a ping and fail `yum install`. The key is about 3 KB, stable,
and load-bearing: an unreachable packaging key is a real blocker rather than a
synthetic reachability check.

**The body is checked, not only the status.** A proxy answering 200 with an
authentication page is the failure a status-code check reports as success, and it is
the likeliest one in exactly the corporate networks this exists to describe. Reading
the response is also what a package manager does.

**The proxy in effect is reported whether or not one is set.** Without it the result
cannot be explained: "connection refused" from a host with no proxy and from a host
behind a broken one are the same string and completely different jobs. All four
environment spellings are read, since urllib honours all four. No attempt is made to
redact a proxy URL that carries credentials - it is reported as configured, and
pretending otherwise would be worse than saying so.

`REPO_URL` and `REPO_TIMEOUT` are hot settings. An air-gapped estate mirrors the
repository somewhere else, and checking the public one there would report every host
as broken. The timeout is 8 seconds and deliberately short: a repository slow enough
to exceed that is not usable by a package manager either, so waiting longer only
delays the same answer.

The whole document is kept on the host row rather than flattened to a boolean.
"Unreachable" is not actionable on its own; the status code, the latency and the
proxy are what say whether to go and fix DNS, a certificate, or an allow-list.

Collected once per dispatch, like the OS facts - it describes the host, and asking
once per service would multiply the wait by the services on it. It can never fail a
sweep: a repository check that raised would cost the caller the OS, process and
version facts collected beside it.

Verified on `pmm-client-node00`: reachable in 141 ms with no proxy, then
`URLError: Name or service not known` after pointing `REPO_URL` at an invalid host,
both landing in the row and both readable from `./om discovery estate`.
A sweep has attempted hosts as well as services since a host became probeable for its
own sake, but the receipt only ever counted services. So a refresh of a pmm-client
host - the case POM most exists to describe - reported "0 of 0 services", which is
exactly what a run that did nothing looks like.

Three counters, and the pair is the point: `hosts_total` against `hosts_probeable`
puts a number on the estate nothing can be dispatched to, which is a fact about
onboarding rather than a failed sweep. `hosts_answered` is what actually came back.
The same reasoning as the existing resolved-vs-answered split, one level up.

Forward migration rather than editing the create: the table is already applied
wherever POM has run, and three columns defaulting to zero are cheaper than asking
anyone to rebuild a database to read a new counter. Existing rows read as "this run
predates host counting" rather than as NULL, which every consumer would otherwise
special-case.

Also extracts `_finalise`. The counters are a run's whole receipt and every one of
them is derived, so a mistake is invisible until someone reads a history and draws
the wrong conclusion; as a block inside the sweep there was no way to assert them
without dispatching Nomad jobs. Both sets are taken from the same lists the estate
was written from rather than counted independently, so the receipt and the rows
cannot disagree about what happened.
**The schedule does not go through the endpoint.** Celery beat calls `run_probe`
directly, so the conflict guard living in `POST /runs` protected a user from starting
an overlapping refresh and did nothing at all about the ten-minute schedule starting
one.

Found by reading a receipt, not by reading code. Two full sweeps 31 seconds apart on
this workspace's sandbox: the second enqueued the same probe job for the same host as
the first, the Tasks layer refused the duplicate, and the loser recorded
`HTTPConflictException: 409: Identical queue item already running` against four
replicaset hosts that were perfectly healthy.

That is worse than a confusing receipt. `_apply_attempt` treats the 409 as a genuine
failed attempt, so it moves `failing_since` and increments `consecutive_failures` -
hosts accrue a failure history for a race between two sweeps rather than for anything
wrong with them. It did no lasting damage this time only because the good sweep
happened to finish *last* and overwrite the failure; reverse the order and four fine
hosts read as failing until the next success.

So the check moves into `conflicting_run` in `crud.py`, where both callers reach it,
and `run_probe` makes it before doing any work. Two details that are easy to get
wrong:

- **A run must not refuse itself.** The endpoint creates the row *before* dispatching,
  so the task finds its own `RUNNING` row; without excluding it, every manually
  triggered refresh would skip itself.
- **The reaper had to move with it**, rather than being left in the endpoint. Two
  copies of "how old is abandoned" drift, and the one in the path beat uses is the one
  that matters - a crashed worker wedging the schedule is exactly the case a reaper
  exists for.

**A refused sweep records a run with status `skipped`**, naming what held it, rather
than returning silently. A ten-minute schedule that quietly does nothing leaves a gap
in the history that reads exactly like it having fired and found nothing. `skipped` is
neither a failure nor a success - it did nothing, deliberately - so the UI gives it a
neutral chip rather than a red one, or a red row would appear every time the schedule
met a manual refresh.

The migration widens the status CHECK constraint, and does it with the enum member
**names**. `EnumField` persists `ProbeRunStatus.RUNNING` as `"RUNNING"` while its value
is `"running"`, so the first attempt built the constraint from the values and was
rejected by every row already in the table. `SettingClassEnum`'s docstring records that
trap; this walked into it anyway, which is why the migration now says so in a comment.

Verified against the sandbox by racing it deliberately: a manual refresh during another
answers 409 naming the run, and `run_probe` invoked directly - exactly what beat does -
records `SKIPPED` instead of dispatching. No 409s in any subsequent run, and the
replicaset hosts hold zero failures.

Five tests cover what went unnoticed: a full sweep blocks another full sweep, a run
does not refuse itself, disjoint scopes do not block each other, an abandoned run is
reaped rather than honoured, and the message names the hosts that are held.
Unfolding a refresh on the Discovery page showed a table of services, so a machine
carrying a PMM client and no database appeared nowhere in it - however many times the
refresh had probed it. The counters said three hosts answered; the receipt could name
one. That host is the case POM most exists to describe: it is where a database can be
installed, and it has no service to be listed through.

A sweep attempts **hosts**. It has since a host became probeable for its own sake, and
the receipt was the last part still shaped as though services were the unit of work.
`nodes` is now one entry per host - node id, name, executor host, how it was matched,
whether the host answered, how long it took, its error - with the services on it
nested underneath, carrying only what is theirs.

Two things fall out of that shape rather than being decided separately:

- **A host's duration is reported once.** It used to be copied onto every service the
  host served, which read as several measurements of several things when it was one
  measurement of one dispatch.
- **"Answered" stops being ambiguous.** Whether the *host* answered and whether its
  *services* did are different questions, and a host with no database answers
  perfectly well while having no services at all. Flattened together there was no way
  to say that.

`services: []` is a meaningful answer and renders as "none" rather than as an empty
cell, for the same reason `pom.host` keeps rows for hosts with no service.

Still outcomes and never observations: what the probe *found* belongs to the estate,
which is upserted and stays current. A receipt carrying the attributes as well would
be a second copy going stale on the next refresh.

The sweep tests move with it, and one of them changes meaning: it used to assert the
duration was repeated across a host's services, which is precisely the behaviour being
removed. It now asserts the opposite, and a new test covers the case that could not be
expressed before - a probed host with no database appearing in the receipt at all.

Regenerated with `buf generate --path pom/v1/pom.proto` rather than `make gen`, which
rewrites 56 unrelated API files from a different protoc-gen-go.
…ally has

`pmm-server` had failed its probe 183 consecutive times with
`SyntaxError: f-string: expecting '}'`, while every database host answered fine.

The Tasks layer runs each payload through `python-minifier` before dispatch, and the
minifier normalises inner string quotes to double. So

    f"mongodb://{userinfo}{target['host']}:{target['port']}/?{'&'.join(options)}"

is dispatched as `f"...{"&".join(E)}"` - a double quote nested inside a double-quoted
f-string. That is PEP 701, and it parses only on **Python 3.12 and later**.

The hosts do not agree on Python. This workspace's database containers carry 3.12.3
and `pmm-server` carries 3.9.25, so the identical payload was valid on nine hosts and
a syntax error on the tenth. The source line was legal on every Python there has ever
been; the bug existed only after minification, and only on some machines. It is
invisible in review, invisible in a local run, and invisible on most of the estate.

So every value is bound to a name before the f-string. That is a rule with a reason
rather than a style preference: the payload runs on whatever Python a monitored host
happens to have, which is not ours to choose, and keeping expressions out of
f-strings is what stops the minifier being *able* to emit the construct.

The regression test minifies the payload and asserts no f-string carries its own quote
character inside an expression. Worth knowing how it was validated, because the
obvious check is wrong: reintroducing `target['host']` does **not** reproduce the bug -
the minifier hoists subscript literals into constants. The trigger is a method call on
a literal, `'&'.join(...)`, and the test was confirmed to fail on that and pass once
reverted. Checking only the subscript form would have suggested a broken test.

Verified on the host that was failing: a scoped refresh of `pmm-server` now records a
success and clears its error.
Rebasing onto the current `pmm` changed what `make regen-specs` emits, in two
unrelated ways.

The specs and the POM OpenAPI snapshot pick up the new base. `MessagesSettings`
is gone from `SettingClassEnum` and `HealthReportSettings` is in it, and
SEP-1708's `options` field and its `SettingOption` schema now appear on every
settings response, POM's included. `sep.json` also loses `ServiceResponse` and
`PaginatedResponse_ServiceResponse_`: the endpoints returning them were retired
earlier in this branch, but the base had renamed the paginated wrapper in the
same hunk, so the conflict was resolved toward the base rather than guessing.
Regenerating is what actually drops them.

The generated TypeScript client is regenerated for the first time on this
branch. No commit here had ever touched `frontend/packages/api/src/generated/`,
so the committed client lagged this branch's own spec changes: `tasks.ts` was
missing `/hosts/states/`, and neither `tasks.ts` nor `inventory.ts` knew
`PomDiscoverySettings`. The specs were committed without regenerating the client
that is derived from them.

Regenerated with the three steps `make regen-specs` runs.
`tests/app/test_openapi_specs_fresh.py` passes and a second codegen run is a
no-op, so the artifacts are at a fixed point.
Rebasing onto the current `pmm` moved three things POM depends on, and git
flagged none of them: the files that broke are new in this branch, so there was
no conflict to resolve.

`app.sep.deps.get_api_authenticated_user` and `validate_csrf` are gone, removed
with the legacy Jinja SSR layer in SEP-1687. Four POM test modules imported them
and failed at collection, taking 49 tests down with them. Three of the four
already overrode `get_current_user` alongside the removed guard, so dropping the
dead override there is a no-op; `test_contract.py` overrode only the removed one,
so it moves to `get_current_user` (what `IsApiAuthenticated` now wraps) or the
guard it mounts is never satisfied and every case 401s. CSRF on mutations is
`require_bearer_for_unsafe_methods`, which these fixtures already override.

`REDUCED_ACTIVATION` mirrors the baked side-car profile, and that profile
activates `pom_discovery` as of this branch, so the mirror gains it too.
`tests/sidecar/test_embedded_settings.py` is what compares the two.

Also pins POM's routing under `SEP.ROOT_PATH`. SEP-1794 landed in the base while
this branch was out, and it is what retires the nginx prefix-stripping this
branch used to carry: `sidecar/settings.yaml` sets `ROOT_PATH: /sep` and
activates `pom_discovery` together, and pmm-managed pulls `GET /services` through
that prefix. POM builds no self-referential URLs, so the prefix is a routing
concern only; this asserts the routes resolve under it instead of leaving it to
inference.

The side-car comment naming `/api/apps/pom_discovery/facts` is corrected to
`/services`. `GET /facts` was retired earlier in this branch, so the comment
named an endpoint that no longer exists.
SEP-1728 added `.github/labeler.yml` after this branch was cut. Its app section is
generated from the apps on disk by `scripts/sync_labeler_apps.py`, and
`tests/scripts/test_sync_labeler_apps.py` asserts the committed file matches, so a
new app has to appear there or CI fails.

Only the `app:pom_discovery` entry is added, and it is written by hand rather than
by running the sync script: the script walks `app/sep/apps/*` on disk, so it also
picks up any stale directory a local checkout happens to carry, and running it
would commit labels for apps that are not in the repository.
@plebioda
plebioda force-pushed the PMM-15326-pom-inventory branch from ff40b01 to d364b39 Compare August 20, 2026 09:21
The product is OpenManager, not PSMDB OpenManager, so `pom` becomes `om`
throughout. The app's own half of the name follows what it actually serves: an
estate of hosts and services that PMM presents as Inventory, not a run of
discovery. `POM Discovery` in the app registry is `OpenManager Inventory`.

So: `app/sep/apps/pom_discovery/` -> `app/sep/apps/om_inventory/`, the shared
`app/sep/pom/` -> `app/sep/om/`, `SEP.POM` -> `SEP.OM` and `SEP.POM_DISCOVERY`
-> `SEP.OM_INVENTORY`, `PomDiscoverySettings` -> `OmInventorySettings`,
`PomHost`/`PomService` -> `OmHost`/`OmService`, the Celery task
`sep__run_pom_probe` -> `sep__run_om_probe`, and the `settingclass` enum value
`pom_discovery` -> `om_inventory` in all three alembic branches.

The database moves with it: schema `pom` -> `om`, so `om.host`, `om.service`
and - discovery being inventory here too - `om.inventory_run`. The symbolic
token the models declare is `om_schema`, and the indexes are `ix_om_*`.

The three migrations that create and alter those tables are edited in place
rather than joined by a rename migration. None of them has shipped: the branch
is unmerged, and a rename migration would leave the old names in the tree
permanently for the sake of databases that only exist on our own laptops. The
cost is that an existing dev database still holds the `pom` schema and will not
migrate across - drop it and re-run (`./om reset data`, then bootstrap).

Derived artifacts are regenerated, not edited: the `GET /schema` and OpenAPI
snapshot goldens, and `frontend/packages/api/specs/*.json` via
`scripts/dump_openapi.py`. The generated TS client under
`frontend/packages/api/src/generated/` is renamed by hand, because `pnpm
codegen` wants a newer Node than this checkout has; it is verified against the
regenerated spec instead - same 169 operation ids, same path and schema
ordering, so it is what codegen would have produced.

`pom_worker` and `pom_api` were cited in a docstring as where some of this code
came from. Those apps were deleted, and the commits that held them will be
squashed away, so the citation goes rather than being renamed to something no
git history contains.

Verified: 9512 tests pass, ruff clean, `alembic check` fails exactly as it did
before the rename (the symbolic schema cannot be compared on SQLite).
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.

1 participant