feat(pg): PostgreSQL datastore compatibility layer - #6
Open
dnplkndll wants to merge 83 commits into
Open
Conversation
dnplkndll
force-pushed
the
feat/pg-compat-clean
branch
3 times, most recently
from
May 14, 2026 12:57
dbcec59 to
6062962
Compare
dnplkndll
force-pushed
the
feat/pg-compat-clean
branch
2 times, most recently
from
May 23, 2026 15:53
a3b9ccf to
f9dab02
Compare
dnplkndll
force-pushed
the
feat/pg-compat-clean
branch
from
July 27, 2026 12:24
10cd4d0 to
1e6ac95
Compare
<!-- Add the related story/sub-task/bug number, like Resolves fleetdm#123, or remove if NA --> **Related issue:** Resolves fleetdm#49421 Custom host vitals (`$FLEET_HOST_VITAL_<id>`) already worked in scripts and Apple/Windows configuration profiles, but Android configuration profiles and managed app configuration explicitly rejected them at upload to keep parity with `$FLEET_SECRET_*`. This left admins unable to inject per-host vitals (e.g. an asset tag) into Android MDM configuration the same way they can for every other platform. For more context, prior PRs: - fleetdm#49334 - fleetdm#49586 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually - Created an "Asset tag" host vital. - Enrolled an Android device. - Initially the test profile showed as "Failed" because no value was set for the vital. - Set a value for the vital, saw that it went from Enforcing to Verified. <img width="1446" height="510" alt="Screenshot 2026-07-24 at 8 57 46 AM" src="https://github.com/user-attachments/assets/c0e2348c-e521-48f3-85cd-6f884689b2cd" /> <img width="1520" height="936" alt="Screenshot 2026-07-24 at 8 56 56 AM" src="https://github.com/user-attachments/assets/169b9545-ec7a-429b-8f45-0e2740f61c77" /> <img width="1607" height="1136" alt="Screenshot 2026-07-24 at 8 57 30 AM" src="https://github.com/user-attachments/assets/a8213745-b224-4a36-a54d-32152a15c377" /> Also tested the rejection cases: - trying to upload a profile with an invalid custom host vital id (either a non-numeric value, a numeric but non-existent ID, and referencing a vital as a JSON key instead of a value) - deleting a vital referenced in a profile https://github.com/user-attachments/assets/e8b4acde-ddf4-41c0-b00a-5ab4945d0bc2 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Android app configurations and profiles now support custom host vital placeholders (`$FLEET_HOST_VITAL_<id>`). * Custom host vital values are expanded per device during Android delivery. * Managed Android profiles/configurations are automatically resent when a referenced vital value changes. * **Bug Fixes** * Added validation for malformed, missing, or undefined vital references during Android app association and profile/config uploads. * Prevented deletion of vitals referenced by Android profiles. * Improved error handling and delivery failure details when a device lacks a required vital value. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
<!-- Add the related story/sub-task/bug number, like Resolves fleetdm#123, or remove if NA --> **Related issue:** Resolves fleetdm#50122 <img width="730" height="148" alt="image" src="https://github.com/user-attachments/assets/e7a803bd-b50d-404f-8af2-9fa32cc34772" /> # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. (Unreleased bug) - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Account provisioning settings now indicate when the feature is included with Fleet Premium. - Premium-tier accounts continue to see the full provisioning configuration and save controls. - **Bug Fixes** - Improved license-tier handling so account provisioning displays the appropriate experience for free and premium plans. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
- [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [X] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [X] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security Improvements** * Updated device-authenticated policy and host-detail responses to omit policy author identity fields and any raw SQL/query data. * Device policy endpoints now return a device-safe policy representation consistently. * **Bug Fixes** * Prevented administrative policy information from appearing in device-authenticated host details and policy listings. * **Tests** * Strengthened integration coverage to verify device-safe responses (required user-facing fields present; sensitive fields absent). <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…etdm#50136) <!-- Add the related story/sub-task/bug number, like Resolves fleetdm#123, or remove if NA --> **Related issue:** Resolves fleetdm#40074 unreleased bug <img width="539" height="141" alt="image" src="https://github.com/user-attachments/assets/1ac8e2c2-236d-4567-a200-0eb35cce46e7" /> # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved configuration profile validation for unescaped special characters in Apple payloads. * Error messages now consistently indicate when characters like `&` and `<` must be XML-escaped. * Updated error examples to show properly escaped guidance. * Expanded test coverage to verify the standardized XML-escaping error for additional failing scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
dnplkndll
added a commit
that referenced
this pull request
Jul 29, 2026
The PR #6 merge gate: the PG CI job now runs the entire datastore package unfiltered — 121 top-level tests pass on PG, 59 MySQL-only suites skip visibly (CreateMySQLDS now gates on MYSQL_TEST like CreateDS), zero failures. Test-infra parity: - No builtin-label pre-seeding on PG (MySQL test DBs are schema-only; tests create their own labels — the seeds collided on idx_label_unique_name). - PG TruncateTables clears the in-process software-title cache like the MySQL path (stale cache made UpdateHostSoftware skip recreating titles). - users_test/jobs_test converted to CreateDS; the users timestamp assertion gets an explicit 1s ceiling on PG (MySQL's whole-second TIMESTAMP gave the same slack implicitly). Cross-dialect datastore fixes: - MySQL-only 'DELETE alias FROM ... JOIN' → keyed subqueries (apple device names ×2, software title display names). - Raw REGEXP → dialect.RegexpMatch with dialect-correct word boundary (MySQL \b, PG \y) in the device-name secret scan. - Windows profile upsert bound command_uuid as an arg instead of a MySQL-only bare column reference in VALUES. - Jobs: not_before defaults to an app-clock whole second and unpinned queue reads compare against DB NOW() — immune to container clock skew and NOW()-precision differences (MySQL rounds, PG keeps micros). - query_results cleanup used LIMIT inside IN (MySQL error 1235 — a fork conversion bug on the MySQL path); wrapped in a derived table. Driver: - SUBSTRING_INDEX(x,d,1) → split_part; JSON_TYPE(x) → upper(jsonb_typeof(x)) with balanced-paren scan; unique violations gain MySQL-style 'table.constraint' phrasing via TranslateError (message-assertion parity). - default_query_exec_mode=describe_exec: pgx's statement cache breaks under live DDL ('cached plan must not change result type') — our deploy shape. Migration 20260729120000 ports the four remaining orphaned generated columns (windows profile checksum, apple declaration token, software_titles additional_identifier, calendar_events uuid) as triggers + backfill; baseline regenerated, marker 20260729120000. operating_systems_test's raw ODKU became dialect-neutral insert-if-missing. MySQL parity verified on every touched suite; both dialects green. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
<!-- Add the related story/sub-task/bug number, like Resolves fleetdm#123, or remove if NA --> **Related issue:** Resolves fleetdm#49811 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed Fleet-maintained app updates when a rebuilt installer keeps the same version. * Rebuilt installers now update their files, hashes, filenames, and install scripts correctly. * Prevented installers from being incorrectly skipped when their contents differ despite matching versions. * **Tests** * Added coverage for same-version installer rebuilds and team-specific caching behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
The gitops-auto-complete uses `replace github.com/fleetdm/fleet/v4 => ../..` which means it is frequently broken whenever fleet updates shared libraries. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Added automated build and dependency verification for the GitOps auto-complete tool. * Updated workflow triggers so changes to the tool are checked automatically. * Refreshed supporting service dependencies used by the tool. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Automated ingestion of latest Fleet-maintained app data. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for the latest versions of AnyBurn, BetterZip, Canva, Eclipse Temurin, ExifCleaner, Gemini, LibreOffice, NordVPN, Postman, TablePlus, Tailscale, and other maintained applications across Windows and macOS. * **Bug Fixes** * Improved macOS uninstall cleanup for numerous applications by removing additional caches, preferences, recent-document entries, containers, support files, and related data. * Updated installer downloads and verification checks to match the latest releases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: allenhouchins <32207388+allenhouchins@users.noreply.github.com>
Co-authored-by: Allen Houchins <allenhouchins@mac.com>
Fixed spelling on LinkedIn posting. <!-- Add the related story/sub-task/bug number, like Resolves fleetdm#123, or remove if NA --> **Related issue:** Resolves # # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [ ] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed ## Database migrations - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [ ] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [ ] Verified that any relevant UI is disabled when GitOps mode is enabled ## fleetd/orbit/Fleet Desktop - [ ] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [ ] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [ ] Verified that fleetd runs on macOS, Linux and Windows - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))
Adds a Postgres backend to Fleet's datastore alongside the existing
MySQL. Non-breaking: MySQL remains the default and is unaffected.
Core pieces:
- DialectHelper interface (server/datastore/mysql/dialect.go) abstracts
SQL dialect differences for upserts, aggregates, JSON ops, error
classification, and atomic swap-table DDL. mysqlDialect + postgresDialect
implementations, dialect.IsPostgres() routes runtime branches.
- pgx-rebind driver (server/platform/postgres/rebind_driver.go)
transparently translates MySQL SQL to Postgres at query execution time
via 50+ regex-based rewrites compiled once at startup. Per-table-name
regexes cached in sync.Map. knownPrimaryKeys map drives ON DUPLICATE
KEY → ON CONFLICT (<pk>) DO UPDATE rewriting.
- Embedded PG baseline (server/datastore/mysql/pg_baseline_schema.sql,
pg_baseline_post.sql) seeded from production pg_dump. Carries a
pg-baseline-up-to-migration: <ts> marker; fresh-apply seeds
migration_status_tables from code and logs a loud warning whenever
code carries migrations newer than the baseline. Object-ownership is
reasserted on every startup so atomic table swaps work even when the
baseline was loaded as the postgres superuser.
- server/goose/migration.go gains UpFnPG / DownFnPG / UpFnMySQL /
DownFnMySQL fields so individual migrations can target one dialect.
First user: 20260513210000_AddMissingPGIndexes (this commit).
- 349 missing PG indexes added via the AddMissingPGIndexes migration
(UpFnPG-only), bringing PG to index parity with MySQL on hot paths
like host_software_installed_paths (host_id, software_id).
Wiring:
- FLEET_MYSQL_DRIVER=postgres selects the new driver; standard
FLEET_MYSQL_ADDRESS / USERNAME / PASSWORD / DATABASE env vars route to
the PG cluster unchanged.
- server/config/config.go validates the new driver value.
- cmd/fleet/prepare.go threads dialect into the migration apply path.
- docker-compose.yml gains a postgres service for local dev.
Tests:
- 39 PG smoke tests (hosts, software, vulnerabilities, policies,
host-counts) and B1/B2/B3 tiers running on both backends via the new
CreateDS(t) helper.
- Driver-rewrite unit tests cover every regex (UPDATE...JOIN,
DELETE USING, GROUP_CONCAT, ON CONFLICT ambiguity resolution,
smallint-bool encoding, MAX(bool), INTERVAL placeholder, CAST NULL
AS SIGNED, FIND_IN_SET, COALESCE token, null-byte stripping, ...).
- Dialect unit tests for both dialects (LAST_INSERT_ID stripping,
ReturningID, AtomicTableSwap, CreateTableLike).
- List-options helper has new coverage for single-aggregate ORDER BY
skip and text-column cursor binding.
- Benchmarks for UpdateHostSoftware / ListSoftware / ListHosts in
server/datastore/mysql/benchmarks_test.go.
Squashed from 70+ incremental commits on feat/pg-compat-clean; full
provenance preserved on feat/pg-compat-clean-backup-2026-05-13.
…p on dep-review
CI infrastructure that gates the PG backend:
- test-go-postgres.yaml: spins up Postgres in a service container, runs
the full datastore + service test suites against the PG driver. Mirrors
the existing MySQL test workflow.
- validate-pg-compat.yml: invokes the tools/pgcompat validators on every
PR/push — check_primary_keys, check_schema_drift, check_column_drift.
Empty-allowlist gate-of-the-gate test ensures the validators themselves
can never become a no-op.
- build-ledo.yml: ledoent-specific image build that refuses to publish to
ghcr.io unless both test-go-postgres and validate-pg-compat succeeded
on the build SHA.
- sync-upstream.yml: paranoia check that refuses to force-push ledoent/main
if any non-bot commits exist outside upstream/main.
- weekly-aggregate.yml: gitaggregate cron + workflow_dispatch, pinned to
git-aggregator==4.1.
- dependency-review.yml: skip on private repos (the action requires
GitHub Advanced Security which isn't available on free private mirrors).
Upstream public fleetdm/fleet still runs it.
- test-website.yml: npm audit step added so frontend dep regressions
block PRs.
- tools/ci/apiparamcheck: custom golangci-lint plugin that flags REST
handler params not registered in the request struct, catching the
'missing query param decode' class of bug.
…rift
Three small static-analysis tools that prevent silent PG-compat regressions.
None require a running Postgres; they read Go source and SQL schema files.
- check_primary_keys: scans non-test Go for raw 'ON DUPLICATE KEY UPDATE'
SQL and verifies every targeted table has an entry in knownPrimaryKeys
(the map in server/platform/postgres/rebind_driver.go that drives the
ON CONFLICT (<pk>) DO UPDATE rewrite). Missing entries produce invalid
PG SQL at runtime.
- check_schema_drift: diffs CREATE TABLE identifier sets between
server/datastore/mysql/schema.sql (MySQL canonical) and
pg_baseline_schema.sql (PG baseline). known_schema_diff.txt records
intentional divergence and is itself validated — stale entries fail.
- check_column_drift: diffs column lists per shared table. Optional
allowlist via known_column_drift.txt.
- gen_identity_cols / gen_bool_cols: code generators that produce the
Postgres dialect's static knowledge of IDENTITY columns and bool
columns so the rebind driver can rewrite INSERTs correctly.
- validators_test.go is a gate-of-the-gate: an empty schema-diff
allowlist must produce a non-zero exit.
Designed to be extractable as a standalone PR to fleetdm/fleet — they're
useful to any Fleet operator building PG support, with or without the
larger driver/baseline layer.
Playwright API-mode test matrix that exercises every URL filter Fleet's
frontend can construct against a live server, asserting each response is
not a Postgres-driver or Postgres-syntax failure (SQLSTATE, 'must appear
in the GROUP BY', 'operator does not exist', 'cannot find encode plan',
'syntax error', etc.).
Read-only (HTTP GET only). ~220 probes in ~15s with 8 workers.
Coverage:
- /hosts + /hosts/count: status, low_disk_space, mdm_enrollment_status,
os_settings/apple_settings/disk_encryption/bootstrap_package, populate_*,
every ORDER BY allowlist key × direction, cursor pagination (after=),
vulnerability filter, search.
- /software/versions, /software/titles, /software (deprecated):
vulnerable, exploit, cvss range, self_service, available_for_install,
packages_only, team filtering, ordering.
- /vulnerabilities, /host_summary, /labels/:id/hosts, /hosts/:id/*,
sanity endpoints (/config, /version, /me, /labels, /teams, ...).
Run:
cd tools/pg-compat-harness
yarn install
export FLEET_URL=https://your-fleet
export FLEET_TOKEN=$(awk '/token:/ {print $2}' ~/.fleet/config)
yarn test
This harness found and gated the GROUP BY and cursor-encoding regressions
fixed elsewhere in this branch (selectSoftwareSQL GroupByAppend,
AppendListOptionsWithParamsSecure textOrderKeys hint).
Small Go program that parses server/datastore/mysql/schema.sql and emits
one CREATE INDEX IF NOT EXISTS statement per MySQL KEY / UNIQUE KEY clause,
suitable for embedding into a PG-only migration.
Handles:
- balanced parens in column lists (expression bodies)
- USING BTREE / USING HASH suffix (MySQL hint, PG ignores)
- DESC column ordering (PG supports natively)
- identifier quoting where required
- stable per-table grouping for reviewable diffs
Deliberately skips with explicit reasons:
- PRIMARY KEY (the CREATE TABLE handles it)
- FULLTEXT KEY, SPATIAL KEY (need pg_trgm / GiST equivalents)
- prefix-length indexes col(N) (need PG expression indexes)
- expression indexes using MySQL-specific functions (ifnull, cast as
signed) that need PG translation (COALESCE, CAST AS integer)
main_test.go drives translate() from inline schema fixtures — no file I/O
required. Covers plain/unique keys, DESC, USING BTREE, every skip reason,
balanced-paren edge cases, multi-table, PRIMARY ignored, plus unit tests
for extractParenBody and quoteIdent helpers.
Usage:
go run ./tools/pg-index-translate \
-in server/datastore/mysql/schema.sql \
-out server/datastore/mysql/migrations/tables/{ts}_AddMissingPGIndexes.sql
- docs/Deploy/postgresql.md: end-to-end guide for running Fleet against
Postgres — connection env vars, baseline schema apply, migration
apply, ownership reassertion, troubleshooting (drift warning, must
be owner of table, schema/column drift validator output).
- docs/Deploy/README.md: links the new guide from the deployment index
alongside the MySQL guide.
GetDBVersion returned a too-old current version on production PG because the baseline-seed path (and goose's own run-and-record loop for newly introduced migrations) inserted rows into migration_status_tables out of version_id order. Concretely, id 523 carried version 20260422181702 while id 521 carried 20260506171058. Plain 'ORDER BY id DESC' picked the older version, so 'fleet prepare db' tried to re-run every migration from 20260423161823 onward and failed on json_merge_patch — a MySQL-only function that PG never had, with the migration body long since folded into the embedded baseline. Switching to 'ORDER BY version_id DESC, id DESC' makes the query immune to insertion order while preserving up/down semantics: the tie-break by id DESC keeps the most recent applied/rolled-back state for the same version. MySQL is unaffected — its migration runner always applies in monotonic version order so id and version_id stay aligned. We do not change the MySQL dialect to keep blast radius minimal; that path has years of behavior to preserve. Test pins the exact ORDER BY clause via sqlmock so any future change back to the buggy form fails CI loudly.
…ces/views pg_baseline_post.sql already loops over public tables, sequences, and views and reasserts ownership to current_user, but it skipped functions. On baselines that were loaded by the postgres superuser (typical on self-hosted PG), CREATE OR REPLACE FUNCTION later in the same file errored with 'must be owner of function fleet_set_updated_at' — the application user can't replace something it doesn't own. Add a fourth loop using pg_proc / pg_namespace to enumerate public functions whose owner is not current_user, and ALTER FUNCTION ... OWNER TO current_user with the standard insufficient_privilege fallback. pg_get_function_identity_arguments() disambiguates overloaded signatures. Hit in production tonight on the AddMissingPGIndexes deploy. With this fix every future fleet prepare db on a postgres-superuser-loaded baseline succeeds without manual ALTER FUNCTION.
The existing implementation already sorts the seeded versions ascending (via versionsAtOrBelow → partitionMigrationVersions → slices.Sort), so PG assigns auto-increment ids in the same order as version_id. That property is load-bearing for any downstream consumer that infers 'current version' from MAX(id), even with the dialect query now correctly ordered by version_id DESC. No functional change — just document the invariant so a future refactor doesn't quietly drop the sort.
Review findings 17, 18, 22 + scanner nits:
- UPDATE/DELETE ... LIMIT is no longer silently stripped: the driver rejects
it at Exec/Query/Prepare with a descriptive error. The three runtime sites
are rewritten cross-dialect (in_house_apps keyed-subquery batch; statistics
and nanomdm bstoken drop redundant LIMIT 1).
- The innodb/sql_mode SELECT-1 replacement is anchored on leading SET/SHOW;
DBLocks (pg_blocking_pids) and InnoDBStatus (n/a placeholder) get explicit
PG implementations instead of relying on query swallowing.
- Bool-literal rewrite is single-pass and identifier-anchored: suffix
collisions (num_canceled vs canceled, *_encrypted vs encrypted) can no
longer mis-rewrite. First tests for it and rewriteOnDuplicateKey.
- rewriteOnDuplicateKey's unknown-table fallback emits a self-describing
conflict target naming the missing knownPrimaryKeys entry instead of
invalid SQL with an opaque parse error.
- The ?→$N scanner skips single-quoted literals ('' escapes) and /* */
block comments, so a literal question mark can't shift later ordinals.
- PrepareContext applies the RETURNING rewrite via a wrapping statement whose
Exec routes through Query — prepared identity-table inserts no longer
silently return LastInsertId()==0.
Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Review findings 15, 16: - seedPGMigrationHistory runs unconditionally (its empty-table guard keeps it idempotent): a psql-loaded baseline no longer causes goose to replay every migration against a populated schema. - New checkPGBelowMarkerDrift rebase backstop: a table migration numbered below the baseline marker with no applied record (upstream authored-early / merged-late) fails prepare with remediation guidance instead of being silently skipped forever. Covered by TestPostgresBelowMarkerDriftCheck. - MigrateData is no longer an unconditional no-op on PG: seeded history means goose only runs post-marker data migrations, and MigrationStatus can no longer wedge serve on the first new data migration. - pg_baseline_post.sql: the nano_view_queue drop+recreate runs inside one DO block and the software_titles trigger uses CREATE OR REPLACE TRIGGER — no observable window where either object is missing while old pods serve. Header no longer claims it runs on startup. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Review findings 12b/c, 19, 20: - check_primary_keys now verifies every knownPrimaryKeys entry's columns against a real PK/UNIQUE constraint in the baseline (catches wrong-column entries as CI failures naming SQLSTATE 42P10, the class Phase 2 fixed by hand); attribution of ODKU→INSERT is bounded to ~120 source lines instead of an 8KB byte window across function boundaries; ee/ and cmd/ are scanned. - gen_identity_cols reads schema.sql (regenerated by upstream on every migration) instead of the PG baseline. The switch immediately surfaced a real gap: host_scd_data (sequence-default id, not IDENTITY syntax) was missing from the map, so its PG inserts returned LastInsertId()==0. - New check_bool_col_split validator fails when a column name is boolean in one table and smallint in another (the name-keyed-rewrite hazard class; acme_*.revoked was the live instance). awaiting_configuration's intentional tri-state split is allowlisted with rationale. - All three wired into CI and make check-pg-compat with negative tests. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
… leftovers Review finding 21 + activities audit: - common_mysql.IsReadOnlyError recognizes PostgreSQL's SQLSTATE 25006 (matched structurally via SQLState(), no pgx dependency), so every fail-fast site — sessions, withRetryTxx, common tx helpers — now trips on a PG primary→replica failover exactly as on Aurora demotion. - Prod audit: activities/host_activities (pre-rename generation the MySQL-only RENAME TABLE never converted on PG) are empty; activity_past holds all history. Migration 20260727210000 drops them with an emptiness guard that fails loudly if any deployment has stranded rows. known_schema_diff.txt comments corrected (they were mislabeled 'no MySQL equivalent'). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Review nits: - FullTextMatch concatenates multi-column input into one tsvector instead of silently dropping cols[1:]. - docs/Deploy/postgresql.md: knownBooleanColumns → generated schema_bool_cols_gen.go reality; prepared-statement LastInsertId rows marked resolved (fixed in 3.1); stale claims corrected. - Dead code removed: goose AddDualDialectMigration (20260513210000 sets UpFnPG directly), unreachable pgx branch in tables.SetDialect; indexExists documented as MySQL-test-only (migration code must use the dialect-aware indexExistsTx). - validate-pg-compat skip ledger counts isPG(ds) sites — the TODO-string grep matched nothing and reported Total: 0 forever. - pg-compat-harness playwright config requires FLEET_URL (bare yarn test no longer targets prod). - docker-compose dev postgres uses the same glibc postgres:16 image as postgres_test (musl collation differs; collation_test.go exists) and binds to 127.0.0.1 like every other service. - CLAUDE.md: migration batching convention (finding 25's residue). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Baseline drops the pre-rename activities/host_activities tables; schema-diff allowlist pruned accordingly. All six validators green; fresh + idempotent prepare db verified on scratch. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…marker The first Phase-3 prod deploy aborted on the backstop's own false positive: right after a baseline regen the marker IS the newest (not-yet-applied) migration, which goose is about to run — that is the normal pending state, not drift. Unreachable migrations are exactly those below the database's max applied version (goose only runs versions above it). Regression-covered with the exact prod scenario in TestPostgresBelowMarkerDriftCheck. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…ed_at triggers Adversarial re-review (2026-07-28) found 3 must-fix issues; all fixed: M1 — UpdateVulnerabilityHostCounts failed every hourly run since the swap change: its old-table cleanup was the one bare DROP TABLE among the four swap callers (the PG swap now drops the table itself). IF EXISTS like its siblings; confirmed live in prod cron_stats before fixing. New TestPostgresHostCountCrons runs all four swap crons end-to-end, twice. M2 — Windows ESP state machine: mdm_windows_enrollments.awaiting_configuration is a tri-state uint but sat in smallintBoolColumns, whose bool-CASE rewrite collapsed Active=2 to 0. Split-typed names are now excluded from ALL generic bool machinery: removed from smallintBoolColumns, gen_bool_cols skips names in known_bool_col_splits.txt (regenerated), both sides map natively. Driver unit test asserts pure passthrough; TestPostgresWindowsESPStateMachine walks the real 0→1→2→0 transitions. M3 — updated_at was frozen at insert time on ~125 tables: MySQL's ON UPDATE CURRENT_TIMESTAMP had a PG trigger on only the 12 driver-created tables. New gen_updated_at_triggers generates the full trigger set from schema.sql (138 triggers/137 tables incl. the three non-updated_at auto-touch columns via a generic fleet_touch_column), embedded and applied on every prepare db (CREATE OR REPLACE, converges driver-created names). fleet_set_updated_at upgraded to exact MySQL semantics: touch only when the row changed and the statement didn't assign updated_at itself. CI staleness check added. TestPostgresUpdatedAtTriggers covers touch/no-op/explicit-wins plus a coverage-breadth assertion (>=130 tables). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Its host_disks scenario is covered by TestPostgresUpdatedAtTriggers. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…opy frozen DRY pass from the branch quality review: - tools/pgcompat/internal/allowlist: single implementation of the tagged (mysql-only:/pg-only:) and plain-lines allowlist formats, replacing three near-identical copies across check_column_drift, check_constraint_drift, check_bool_col_split and the inline loop in gen_bool_cols. - 20260727170400's DO block is applied history — replace the stale 'keep in sync' instruction with a frozen-snapshot note (the dialect re-canonicalizes on every swap cycle, so drift is harmless by design). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…umn coverage Coverage pass from the branch quality review: - newPGTestHost collapses five identical fleet.Host literals in the PG smoke tests (the two tests that deliberately exercise raw NewHost keep theirs). - TestMySQLUpsertDidUpdateParity: the promised MySQL twin of TestPostgresUpsertDidUpdate — OnDuplicateKeyGuarded must yield identical did-update semantics on both dialects (insert/changed → uploaded_at moves, identical re-upsert → preserved). - TestPostgresUpdatedAtTriggers now also covers the fleet_touch_column path via sessions.accessed_at, one of the three non-updated_at auto-touch columns. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
tools/pgcompat/README.md documents all five validators, the three CI-staleness-checked generators, and the shared internal/allowlist package; docs/Deploy/postgresql.md's CI-gates section lists the full current step sequence including constraint-drift, bool-split, and the updated_at trigger generator. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
The PR #6 merge gate: the PG CI job now runs the entire datastore package unfiltered — 121 top-level tests pass on PG, 59 MySQL-only suites skip visibly (CreateMySQLDS now gates on MYSQL_TEST like CreateDS), zero failures. Test-infra parity: - No builtin-label pre-seeding on PG (MySQL test DBs are schema-only; tests create their own labels — the seeds collided on idx_label_unique_name). - PG TruncateTables clears the in-process software-title cache like the MySQL path (stale cache made UpdateHostSoftware skip recreating titles). - users_test/jobs_test converted to CreateDS; the users timestamp assertion gets an explicit 1s ceiling on PG (MySQL's whole-second TIMESTAMP gave the same slack implicitly). Cross-dialect datastore fixes: - MySQL-only 'DELETE alias FROM ... JOIN' → keyed subqueries (apple device names ×2, software title display names). - Raw REGEXP → dialect.RegexpMatch with dialect-correct word boundary (MySQL \b, PG \y) in the device-name secret scan. - Windows profile upsert bound command_uuid as an arg instead of a MySQL-only bare column reference in VALUES. - Jobs: not_before defaults to an app-clock whole second and unpinned queue reads compare against DB NOW() — immune to container clock skew and NOW()-precision differences (MySQL rounds, PG keeps micros). - query_results cleanup used LIMIT inside IN (MySQL error 1235 — a fork conversion bug on the MySQL path); wrapped in a derived table. Driver: - SUBSTRING_INDEX(x,d,1) → split_part; JSON_TYPE(x) → upper(jsonb_typeof(x)) with balanced-paren scan; unique violations gain MySQL-style 'table.constraint' phrasing via TranslateError (message-assertion parity). - default_query_exec_mode=describe_exec: pgx's statement cache breaks under live DDL ('cached plan must not change result type') — our deploy shape. Migration 20260729120000 ports the four remaining orphaned generated columns (windows profile checksum, apple declaration token, software_titles additional_identifier, calendar_events uuid) as triggers + backfill; baseline regenerated, marker 20260729120000. operating_systems_test's raw ODKU became dialect-neutral insert-if-missing. MySQL parity verified on every touched suite; both dialects green. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…ace, migration-test gating The first unfiltered CI run (correctly) caught what my local environment masked: - EnqueueDeviceLockCommand's FOR UPDATE guard relied on InnoDB gap locking when no host_mdm_actions row exists yet — PG has no gap locks, so all 20 concurrent requests in the race test passed the existence check and 4 commands landed. A transaction-scoped pg_advisory_xact_lock keyed on host_id serializes PG; MySQL keeps its gap-lock behavior. This is the first concrete instance of the review's finding-8 gap-lock class; the sibling sites (apple_psso, host_certificate_templates) remain under that finding's accepted-risk disposition pending the same treatment. - migrations/tables tests and migrations_test.go dialed MySQL unconditionally (my MYSQL_TEST shell export hid it locally); both now gate with a visible skip, and the recursive ./server/datastore/mysql/... CI target is clean under a PG-only environment. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
newDSWithConfig and createTestDatabase (mysql_test.go) open MySQL directly; four TLS/password-path tests failed on dial in the PG CI job while passing locally against the dev container. Verified by running the full recursive suite with the local MySQL container STOPPED — the exact CI shape — which now passes clean. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…ations Rebase was conflict-free. Upstream added two migrations timestamped BELOW this fork's baseline marker — the exact scenario the below-marker backstop exists for (it refuses deploys until their DDL lands). Migration 20260729190000 ports both on PG: re-runs 20260727083533's DDL through the rebind driver (with a post-condition assert so a silent no-op can never record as applied), and inlines 20260727084359's fleet-variable insert with a boolean literal (the original's integer 0 for is_prefix is a 42804 on PG). The generated updated_at trigger set now installs AFTER goose Up (in both prepare and the test harness): it references tables that post-marker migrations create. Baseline regenerated through the port (marker 20260729190000, 223 tables); gen files regenerated (128 identity tables, 140 touch triggers); all five validators green; full unfiltered PG suite, driver, goose, migrations-on- MySQL, and fresh+idempotent prepare all pass. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
dnplkndll
force-pushed
the
feat/pg-compat-clean
branch
from
July 29, 2026 15:39
aa253da to
3faeee0
Compare
The fork's root go.mod adds pgx; the new upstream tool module's go.sum needs the matching hashes (CI 'Test tools' gate). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
The first Phase-4 prod migration was refused by checkPGBelowMarkerDrift: prod's history has no rows for the two back-dated upstream migrations, and the check runs before the goose Up that would execute their porting wrapper. Local runs never hit this — fresh DBs seed history through the marker. PortedBelowMarker (declared next to the wrapper) maps each ported version to its wrapper; the check now exempts a version whose wrapper is either already applied or registered above the DB max (so the imminent goose Up runs it). Anything else below the max still fails. The wrapper also records the ported versions in goose history so steady state needs no exemption. New TestPostgresPortBackdatedWrapper executes the wrapper's PG path against a prod-shaped DB (DDL absent, history rows missing) — without it that path would first run in production — plus exemption cases in the drift test. Verified end-to-end: a DB reset to prod's exact pre-port state now runs prepare db to completion (tables, vars, and history all land). Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Prod's second Phase-4 migration attempt failed differently: the software_titles backfill touch fires the baseline-post unique_identifier trigger, and recomputing stale stored values (the formula gained application_id/upgrade_code terms after those rows were written) collided on idx_unique_sw_titles. 95 duplicate titles had accumulated on prod that MySQL's generated column + unique keys make impossible. Up_20260729120000 now merges each duplicate group (grouped by the trigger's current formula) into its lowest id before the touch: per-title aggregates are deleted (crons rebuild), the 14 referencing tables are repointed — deleting the loser's row first where the target's unique key includes the title id — and losers removed. Verified against prod data in a rolled-back transaction: 95 losers merged, 102 software rows repointed, full recompute collision-free, zero orphans. TestPostgresGeneratedColumnDedup fabricates the prod shape (stale unique_identifier inserted with triggers disabled, references attached) and executes the migration's PG path directly. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
…eview Review of the Phase-4 delta (three parallel reviewers) found real gaps in the two new migrations; all are closed here and the dedup coverage now exercises every guarded branch. - 20260727084359: is_prefix literal 0 -> false (valid on both dialects). On a PG database still below that version (older baseline markers) the ORIGINAL runs — the wrapper only covers databases already past it — and the integer literal is a 42804 deploy wedge there. - Dedup migration: policies.patch_software_title_id was missed entirely (the reference scan required the column to be named exactly title_id / software_title_id) — a merged group would have orphaned patch policies silently, since PG carries no FK. software_title_team_pins repointed with no guard against its (team_id, title_id) PK. Both now use the guarded delete-before-repoint. The guard itself also handled only keeper-vs-loser collisions; two losers of one group landing on the same slot collided with each other. All five guarded tables now rank slot occupants (keeper's row first, then lowest loser id) and delete the rest. Prod audited: zero patch policies, zero orphans — the misses did no damage on the one database that already ran this migration. - The backfill touch activates idx_software_titles_bundle_identifier for the first time (NULLs kept it vacuous), which the dedup key does not imply uniqueness under; a pre-flight DO block now fails with the colliding bundle list instead of an opaque 23505. - Wrapper: guards and post-checks BOTH ported tables (a partial manual restore could previously record the port done with one table missing forever), and the fleet-variable insert is per-row idempotent instead of keyed on one variable's presence. - checkPGBelowMarkerDrift: dropped the tautological registered-map lookup (PortedBelowMarker lives in the wrapper's own file). - Tests: dedup test now covers keeper-collision, intra-loser collision, team-pin drop, and patch-policy repoint; stale test comments corrected. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
TranslateError had no direct tests. Table-driven coverage for the wrap (message phrasing, Unwrap, SQLState passthrough and fallback) and the pass-through paths; the wrap now also requires a non-empty ConstraintName so it can never emit a malformed "table." key. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
Five copy-pasted env gates collapse into skipUnlessMySQLTest; the two new PG tests share pgMustExec. GetFilteredQueuedJobs' comment claimed not_before is written with NOW() — NewJob writes the truncated app clock; the comment now states the real invariant and its skew window. Claude-Session: https://claude.ai/code/session_01NrWVk8oMToieU6y3yDtYpY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue: internal PG-compat fork work (no upstream issue)
Checklist for submitter
SELECT *is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.Testing
Database migrations
COLLATE utf8mb4_unicode_ci). (N/A on PG; MySQL paths unchanged.)What changed
Full PostgreSQL compatibility for Fleet's datastore layer, enabling the production deployment at fleet.hz.ledoweb.com to run against PostgreSQL 16 instead of MySQL.
July 2026 rebase onto upstream 5cd8edb (1,114 upstream commits)
isPostgres()branches:ALTER … CHANGE/MODIFY,DROP CHECK, inlineADD [UNIQUE] KEY, combinedDROP/ADD INDEX,VIRTUALgenerated ENUM columns (plain text on PG),IF()in CHECK expressions,REGEXP→~, MySQLinformation_schemaFK/index lookups →pg_constraint/pg_indexes. Idempotency guards added for schema this fork shipped ahead of upstream (pending-delete table,policy_gated, BYOD/ADUE).fleet prepare dbdoes (seed history ≤ marker, goose Up through the rebind driver), so migration incompatibilities surface inTestPostgres*instead of at deploy.fleet prepare db --mysql_driver=postgres; marker bumped to20260724134801.schema_identity_cols_gen.goregenerated; schema/column drift validators clean.ON UPDATE NOW(n)likeON UPDATE CURRENT_TIMESTAMP(emitting the updated_at trigger); ten newknownPrimaryKeysentries for new upstream upsert sites; the twomdm_configuration_profile_variablesupserts (per-kind conflict targets) converted to the dialect helper.connected_to_fleetCASE mixedEXISTS(...)with1/0integer literals — PG rejects mixed CASE branch types (SQLSTATE 42804), which broke/api/fleet/orbit/configon first deploy. Converted totrue/falseliterals (valid in both dialects); regression-covered byTestPostgresGetHostMDM.InsertCVEMetaon PG adds aWHERE … IS DISTINCT FROMguard so re-upserting the ~300k-row NVD set doesn't rewrite unchanged rows (dead tuples + index churn), andLoadCVEMeta's bulk-load deadline is raised 1m → 10m. This unblocks the hourlyinsert cve scores: context deadline exceededfailure observed in prod since at least Jul 12.Core dialect abstraction
DialectHelperinterface (dialect.go) abstracting all MySQL vs PostgreSQL SQL differencesmysqlDialectandpostgresDialectimplementations covering all methods:InsertIgnoreInto,ReplaceInto,FromDual,OnDuplicateKey,OnConflictDoNothing,GroupConcat,JsonQuote,JSONAgg,JSONExtract,JSONUnquoteExtract,JSONBuildObject,JSONObjectFunc,FindInSet,FullTextMatch,RegexpMatch,GoquDialect,ReturningID,IsPostgres,CreateTableLike,AtomicTableSwapRuntime SQL translation (
server/platform/postgres/rebind_driver.go)The pgx-rebind driver wraps pgx/stdlib and rewrites MySQL syntax to PG at the driver layer:
?→$Ncol = 1/col = 0→col = true/col = false(for the ~60 boolean cols listed inschema_bool_cols_gen.go)UPDATE … JOIN→UPDATE … FROM; multi-table DELETE → DELETE … USINGJSON_EXTRACT(col, '$.path')→col->'path';JSON_OBJECT(…)→jsonb_build_object(…);JSON_QUOTE(…),JSON_UNQUOTE(…),JSON_ARRAYAGG(…)→ PG equivalentsIF()→CASE WHEN,IFNULL→COALESCE,MD5()→md5(),UUID()→gen_random_uuid()::text,UTC_TIMESTAMP()→TO_CHAR(NOW() AT TIME ZONE 'UTC', …),CURDATE()→CURRENT_DATE,DATABASE()→current_schema(),HEX()/UNHEX()→encode/decode,FIND_IN_SET→array_position(string_to_array), …CAST AS UNSIGNED→CAST AS integer,CAST AS SIGNED INT→CAST AS integer,TIMESTAMP(?)→(?)::timestampINSERT IGNORE INTO→INSERT INTO … ON CONFLICT DO NOTHING;REPLACE INTO→INSERT … ON CONFLICT DO UPDATE;ON DUPLICATE KEY UPDATE VALUES(col)→ON CONFLICT (pk) DO UPDATE SET col = EXCLUDED.colBLOB→bytea,MEDIUMTEXT/LONGTEXT→TEXT,TINYINT(1)→smallint,DATETIME[(N)]→timestamp[(N)],INT UNSIGNED NOT NULL AUTO_INCREMENT→INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY,INT UNSIGNED→INTEGER, inlineUNIQUE KEY name (cols)→CONSTRAINT name UNIQUE (cols),enum('a','b','c')→VARCHAR(255) CHECK (col IN ('a','b','c')),ENGINE=InnoDB/DEFAULT CHARSET=…/ALGORITHM=INSTANTstrippedALTER TABLE … ADD COLUMN …, ADD KEY <name> (<cols>)produces an ALTER followed by a separateCREATE INDEX.ADD UNIQUE KEY→CREATE UNIQUE INDEX. Multiple ADD KEY clauses each become their own CREATE INDEX.fleet_set_updated_at()is appended. The trigger function is installed bypg_baseline_post.sql.Migration runner
MigrateTableson PG: applies the embedded baseline on fresh DBs, seedsmigration_status_tables/migration_status_data≤ the baseline marker, then runs gooseUpso newer upstream migrations execute through the rebind driver.pg_baseline_post.sql: idempotent ownership fixups +fleet_set_updated_at()trigger function; re-applied on everyprepare db.pgcompat tooling (
tools/pgcompat/)Validators that gate every push:
check_primary_keys— every rawON DUPLICATE KEY UPDATEsite is covered byknownPrimaryKeys, and every entry's columns match a real PK/UNIQUE constraint in the baseline (also run with--include-migrations)check_schema_drift/check_column_drift—schema.sqlvspg_baseline_schema.sqltable and column sets match (allowlists for intentional drift)check_constraint_drift— PK/UNIQUE/index/FK parity by (table, kind, column-set); the deferred ~160-FK set is allowlisted with rationalecheck_bool_col_split— no column name typed boolean and smallint in different tables (split names are excluded from all name-keyed rewrites)gen_updated_at_triggers— generates theON UPDATE CURRENT_TIMESTAMPtrigger mirror (138 triggers) applied on everyprepare db; CI-staleness-checked likegen_bool_cols/gen_identity_colsgen_bool_cols/gen_identity_cols— regenerate the schema-derived driver tables; CI fails if staleprepare db(baseline + post-marker migrations), asserted idempotent on second runTest plan
Validate PG CompatibilityCI — green onfeat/pg-compat-rebased@ dd8ae2b and onaggregatedGo Tests (PostgreSQL)CI — green onaggregatedBuild & Push Ledo Fleet ImageCI — green; imagesha256:6680b5b9eefd…in registry.hz.ledoweb.comTestPostgres*suite against real PG 16, including post-baseline migration replay and the newTestPostgresInsertCVEMetaTestMigrationspasses (MySQL paths of ported migrations unchanged)fleet prepare db --mysql_driver=postgresmigrates a scratch PG cleanly through all July migrations (used to regenerate the baseline)fleet prepare dbjob, 2026-07-27) and rebased image rolled outcve_metatimeout gone, CVE/EPSS data refreshing (verified 2026-07-27)Go Tests (PostgreSQL)CI runs the datastore package unfiltered — 121 pass / 59 MySQL-only skips / 0 fail; driver + goose + pgcompat suites green; fresh + idempotentprepare dbverified post-rebase onto54b32ddf88policies.patch_software_title_idand unguardedsoftware_title_team_pinsrepoints, intra-loser slot collisions in the guarded repoint pattern, a pre-flight guard for the bundle-identifier unique index the backfill newly activates, both-table guards + per-row idempotent variable inserts in the porting wrapper,TranslateErrorunit coverage, and trigger-formula parity tests for all four generated columns. Prod audited for the missed-table gap: zero orphans (no patch policies exist there)PortedBelowMarker), and the generated-column backfill dedups the 95 duplicate software_titles rows that staleunique_identifiervalues had let accumulate (rehearsed against prod data in a rolled-back transaction first). Post-deploy: max version 20260729190000, 0 dup groups, 0 orphans, 138 updated_at-trigger tables, crons completing, hosts checking in