Skip to content

feat(datastore): Phase 1-2 PostgreSQL dialect abstraction#5

Open
dnplkndll wants to merge 32 commits intomainfrom
ledoent
Open

feat(datastore): Phase 1-2 PostgreSQL dialect abstraction#5
dnplkndll wants to merge 32 commits intomainfrom
ledoent

Conversation

@dnplkndll
Copy link

@dnplkndll dnplkndll commented Mar 18, 2026

Summary

Complete dialect abstraction layer enabling PostgreSQL support in Fleet's datastore. Full MySQL and PostgreSQL DialectHelper implementations, dual-dialect goose migration framework, PostgreSQL platform package, Docker Compose + Helm chart support, and an e2e smoke test passing against real PostgreSQL. Zero behaviour change for MySQL.

Phase 1 — Foundation (complete)

  • DialectHelper interface (16 methods), mysqlDialect{} + postgresDialect{}
  • dialect field on Datastore, Driver config, dialectForDriver() wiring

Phase 2 — Runtime SQL migration (74%)

211/286 sites migrated. 73 remain in standalone functions + JSON_OBJECT.

Phase 3 — Migrations (foundation complete)

  • Dual-dialect goose: UpFnMySQL/UpFnPG, selectFn(), DriverName()
  • dialectStep() + migrationHelper interface

Phase 4 — PostgreSQL Alpha (core + infra complete)

  • postgresDialect{}: nested JSON path chaining, VALUES()→EXCLUDED. regex, all 16 methods
  • server/platform/postgres/: SQLSTATE error classification, NewDB()
  • Docker Compose: postgres_test (PG 16, fsync off) + postgres dev instance
  • Test infrastructure: CreatePostgresDS(t) per-test isolation, pgx/stdlib driver
  • Smoke test: INSERT IGNORE, upsert, GROUP_CONCAT, JSON operations — all pass against real PG
  • Helm chart: database.driver field, PostgreSQL bitnami subchart dependency
  • Makefile: e2e-reset-db-pg, e2e-serve-pg targets

Review findings fixed

  • CRITICAL: Nested JSON path chaining ($.mdm.settingcol->'mdm'->'setting')
  • DRY: postgresDialect error classification delegates to platform/postgres
  • Test compat: mockDatastore() nil dialect fix, PG UNIQUE constraint in smoke test

Test plan

  • go build + go vet — clean
  • TestMysqlDialectSQL — 12 subtests
  • TestPostgresDialectSQL — 15 subtests
  • TestTranslateValuesToExcluded — 6 cases
  • TestMysqlPathToPGChain — 6 cases
  • TestMigrationSelectFn — 5 cases
  • TestPostgresSmokeTest — passes against real PG 16 (INSERT IGNORE, upsert, GroupConcat, JSON)
  • MYSQL_TEST=1 integration tests — pass
  • Full POSTGRES_TEST=1 suite — requires baseline schema migration

🤖 Generated with Claude Code

dnplkndll and others added 30 commits March 16, 2026 08:57
Replace LoadLicense() to always return tier=premium, org=Kencove Farm Fence,
999999 devices, expires 2099-12-31. Add multi-stage Dockerfile for building
custom Fleet image (Node 24 + Go 1.25.7 + Alpine runtime).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Builds the custom Fleet image on push to kencove branch and pushes
to gcr.io/kencove-prod/fleet. Uses BuildKit cache via GHA for fast
rebuilds. Requires GCP_SERVICE_ACCOUNT_KEY secret.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
git describe finds the nearest fleet-v* tag in history and uses it
as the image tag (e.g. fleet-v4.80.2 -> v4.80.2-kencove). No manual
version bumps needed when rebasing to a new Fleet release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Switch from GCR + credentials_json to Artifact Registry + WIF with
github-actions@kencove-prod.iam.gserviceaccount.com service account.
Matches pattern from other kencove org repos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Only rebuild on source code changes (cmd/, ee/, server/, frontend/,
  go.mod, package.json, Dockerfile, etc.) — skip README/docs edits
- Add fleet-v* tag trigger for explicit version releases
- Tag pushes bypass paths filter per GitHub docs (always build)
- Smarter version derivation: use exact tag when triggered by tag push

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Make enrollment mode configurable per team via android_settings.enrollment_mode.
Teams set to "fully_managed" get PERSONAL_USAGE_DISALLOWED_USERLESS and the
Google QR provisioning payload in the enrollment token response. The /enroll
page renders a scannable QR code for fully-managed teams instead of the
work profile enrollment link.

Changes:
- AndroidSettings: add EnrollmentMode field + constants
- EnrollmentToken response: add QrCode + EnrollmentMode fields
- CreateEnrollmentToken: resolve mode from team config, set AllowPersonalUsage
- enroll-ota.html: QR code template + rendering for fully-managed
- GitOps validation: reject invalid enrollment_mode values
- CI: build on PRs targeting kencove branch with pr-N image tag

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The enrollment page detects the browser's platform via User-Agent and
shows platform-specific enrollment instructions. For fully-managed
Android enrollment, users open the URL on a computer to display a QR
code for the device to scan. This fix checks enrollment mode before
platform detection so the QR renders on any browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- GitHub Actions workflow to build and push to ghcr.io/ledoent/fleet
- Helm values for fleet.hx.ledoweb.com (single-replica, cert-manager TLS)
- Targets Apple MDM (Mac + iOS) use case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defines the DialectHelper interface in server/datastore/mysql/dialect.go
covering all MySQL-specific SQL patterns that need a PostgreSQL equivalent:
upserts (ON DUPLICATE KEY UPDATE, INSERT IGNORE, REPLACE INTO), aggregate
functions (GROUP_CONCAT, JSON_ARRAYAGG), JSON operators, FIND_IN_SET,
FULLTEXT/MATCH...AGAINST, REGEXP, error classification, and the goqu
dialect wrapper.

Implements mysqlDialect{} in dialect_mysql.go which returns exactly the
SQL currently inlined in datastore queries — pure structural refactoring
with no behaviour change. Error-classification methods delegate to the
existing package-level functions (IsDuplicate, isMySQLForeignKey,
isBadConnection, common_mysql.IsReadOnlyError) to avoid duplication.

Adds postgresDialect{} stub in dialect_postgres.go (build-tagged ignore)
with all methods panicking "postgres: not implemented". The stub exists as
a placeholder for Phase 4 implementation.

Phase 1 of Fleet PostgreSQL support plan (phases 2–5 to follow).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ig field

- Add dialect DialectHelper field to Datastore struct; wire mysqlDialect{}
  in NewDatastore() so all datastore methods have access to the dialect
  abstraction from Phase 1 onward
- Add Driver string field to MysqlConfig (yaml:"driver"); only "mysql" is
  valid in Phase 1 — checkAndModifyConfig rejects any other non-empty value
  with a clear error so misconfigured deployments fail fast rather than
  silently using the wrong dialect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…gments

Replace rigid complete-statement builders (InsertOnDuplicateKeyUpdate,
InsertIgnore, ReplaceInto) with composable fragment methods that work for
all query shapes:

- InsertIgnoreInto() — prefix ("INSERT IGNORE INTO" / "INSERT INTO")
- ReplaceInto() — prefix ("REPLACE INTO" / "INSERT INTO")
- OnDuplicateKey(conflictTarget, updateClause) — suffix
- OnConflictDoNothing(conflictTarget) — suffix

The fragment approach handles single-row, multi-row batch, INSERT...SELECT,
and complex update expressions without requiring one-size-fits-all signatures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…raction

Phase 2, batch 1: migrate all low-frequency MySQL-specific SQL to
ds.dialect.* calls in runtime code (migrations untouched):

- REGEXP (2 sites in software.go) → ds.dialect.RegexpMatch()
- MATCH...AGAINST (2 sites in labels.go) → ds.dialect.FullTextMatch()
- FIND_IN_SET (5 sites in hosts.go, policies.go) → ds.dialect.FindInSet()
- goqu.Dialect("mysql") (10 sites in software.go, hosts.go) →
  ds.dialect.GoquDialect() for *Datastore methods; goquMySQLDialect
  fallback for standalone functions pending further refactoring

Thread DialectHelper through standalone helper function call chains
(loadHostPackStatsDB, loadHostScheduledQueryStatsDB,
cleanupPolicyMembershipOnPolicyUpdate, savePolicy, cleanupPolicy) so they
can access dialect methods.

Zero behaviour change — MySQL output is identical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 2, batch 2: replace GROUP_CONCAT with ds.dialect.GroupConcat()
across runtime code:

- policies.go: GROUP_CONCAT(policy_id) in GetTeamHostsPolicyMemberships
- operating_system_vulnerabilities.go: GROUP_CONCAT(DISTINCT ... SEPARATOR)
  in ListVulnsByOsNameAndVersion (2 sites)
- software.go: 16 GROUP_CONCAT calls in host software listing (installer,
  VPP, in-house app blocks) using local gc shorthand
- software_titles.go: GROUP_CONCAT(JSON_QUOTE(cve)) in version listing
- labels.go, hosts.go: GROUP_CONCAT(JSON_OBJECT(...)) in device mapping

3 sites deferred to next batch:
- apple_mdm.go:1492 (COALESCE+DISTINCT in standalone function)
- apple_mdm.go:5569 (ORDER BY inside GROUP_CONCAT — needs interface
  extension for ordered aggregation)
- testing_utils.go:217 (test infrastructure, MySQL-specific information_schema)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 2, batch 3: route error classification through ds.dialect.* in
*Datastore methods:

- IsDuplicate(err) → ds.dialect.IsDuplicate(err) (24 sites across 18 files)
- isMySQLForeignKey(err) → ds.dialect.IsForeignKey(err) (8 sites)
- isBadConnection(err) → ds.dialect.IsBadConnection(err) (1 site)

Standalone functions (6 sites) retain the package-level IsDuplicate()
wrapper since ds is not in scope — the wrapper already delegates to
mysqlDialect.IsDuplicate() via the same code path.

Convert batchDeleteCertificateAuthorities to a *Datastore method so it
can access ds.dialect for IsForeignKey classification.

Zero behaviour change — all error classification is identical for MySQL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix inconsistent DialectHelper parameter naming: rename dh → dialect in
loadHostPackStatsDB to match all other standalone functions (the
package-level var collision that motivated the shorter name was resolved
when it was renamed to goquMySQLDialect).

Add dialect_mysql_test.go with unit tests covering all mysqlDialect SQL
generation methods — verifies exact output strings for GroupConcat,
FindInSet, FullTextMatch, RegexpMatch, JSON functions, upsert fragments,
and goqu dialect initialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ch 4a)

Replace JSON_EXTRACT() calls with ds.dialect.JSONExtract() in runtime code:

- scheduled_queries.go: 5 sites (aggregated stats fields)
- queries.go: 10 sites (query() function + ListQueries)
- vpp.go: 1 site (payload JSON extraction)

Convert query() standalone function to accept DialectHelper parameter;
thread through newGlobalPolicy, newTeamPolicy, and their callers.

testing_utils.go (5 sites) deferred — test infrastructure.
apple_mdm.go (6 JSON_EXTRACT + 1 JSON_ARRAYAGG) in progress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…(batch 4b)

- apple_mdm.go: 6 JSON_EXTRACT sites → ds.dialect.JSONExtract() in
  recovery lock functions (GetHostsForRecoveryLockAction,
  RestoreRecoveryLockForReenabledHosts, ClaimHostsForRecoveryLockClear)
- apple_mdm.go: 1 JSON_ARRAYAGG → ds.dialect.JSONAgg() in
  ListIOSAndIPadOSToRefetch

hosts.go:1160 intentionally NOT migrated — uses parameterized
JSON_EXTRACT(additional, ?) where the JSON path comes from user input
(AdditionalFilters). Inlining user-controlled paths into SQL would be
unsafe. This site needs special handling in Phase 4 (PG equivalent
requires different parameterization).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tch 5)

Replace INSERT IGNORE INTO with ds.dialect.InsertIgnoreInto() +
ds.dialect.OnConflictDoNothing("") across 13 runtime sites:

- locks.go, query_results.go, labels.go (x2), hosts.go:2734,
  windows_updates.go, in_house_apps.go, software_installers.go:2392,
  software.go (x3), policies.go:1644, scheduled_queries.go:286

For MySQL, InsertIgnoreInto() returns "INSERT IGNORE INTO" and
OnConflictDoNothing("") returns "" — identical output.

4 sites in standalone functions without dialect param intentionally
skipped (operating_systems.go, hosts.go:289/325, software_installers.go:449).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…h 6a)

Replace ON DUPLICATE KEY UPDATE with ds.dialect.OnDuplicateKey("", ...)
across the 6 highest-density files (77 sites):

- apple_mdm.go: 26 sites
- hosts.go: 11 sites (6 skipped in standalone functions)
- android.go: 5 sites (2 skipped in standalone)
- microsoft_mdm.go: 7 sites (1 skipped in standalone)
- scripts.go: 7 sites (1 skipped in standalone)
- policies.go: 6 sites (2 skipped in standalone)

For MySQL, OnDuplicateKey("", clause) returns "ON DUPLICATE KEY UPDATE "
+ clause — identical output. First arg (conflictTarget) is empty for now;
will be populated when postgresDialect is implemented in Phase 4.

Standalone functions without dialect param left as-is (~12 sites).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Revert 8 sites in apple_mdm.go where the ON DUPLICATE KEY agent used
mysqlDialect{}.OnDuplicateKey() directly in standalone functions. This
hardcodes the MySQL dialect, defeating the abstraction purpose.

Restored to inline SQL (consistent with how all other agents handled
standalone functions without a dialect parameter). These sites will be
migrated when DialectHelper is threaded through their call chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrate ON DUPLICATE KEY UPDATE and REPLACE INTO in small files:

- disk_encryption.go (2), calendar_events.go (2), users.go (1),
  teams.go (1), software_title_icons.go (1), software_titles.go (2),
  conditional_access_microsoft.go (1), conditional_access_bypass.go (1),
  ca_config_assets.go (1), app_configs.go (1), aggregated_stats.go (1),
  maintained_apps.go (1), queries.go (1 ON DUPLICATE + 1 REPLACE INTO)

REPLACE INTO in queries.go:1004 migrated to ds.dialect.ReplaceInto().

8 sites in standalone functions without dialect param left as-is
(vpp.go, software_title_display_names.go, setup_experience.go,
packs.go, operating_systems.go, certificate_authorities.go, scim.go).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrate ON DUPLICATE KEY UPDATE in medium-density files (23 sites):

- software.go (6), labels.go (5), software_installers.go (3),
  operating_system_vulnerabilities.go (4), mdm.go (2),
  in_house_apps.go (3), certificate_templates.go (3)

35 sites remain in standalone functions without dialect param — all
intentionally deferred until DialectHelper is threaded through their
call chains (nanomdm_storage.go, hosts.go standalone fns, etc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The mockDatastore() helper in mysql_test.go didn't initialize the
dialect field, causing a nil pointer dereference in
TestGetContextTryStmt when getContextTryStmt calls
ds.dialect.IsBadConnection().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the goose migration framework to support dialect-specific
migration functions, enabling MySQL and PostgreSQL to have different
DDL in the same migration.

Migration struct changes:
- Add UpFnMySQL/DownFnMySQL for MySQL-specific migrations
- Add UpFnPG/DownFnPG for PostgreSQL-specific migrations
- Add selectFn() method: prefers dialect-specific fn, falls back to
  generic UpFn/DownFn

SqlDialect interface:
- Add DriverName() string method to PostgresDialect, MySqlDialect,
  Sqlite3Dialect — returns "postgres", "mysql", "sqlite3" respectively
- Used by runMigration() to select the correct dialect function

Client:
- Add AddDualDialectMigration() for registering migrations with
  separate MySQL and PostgreSQL up/down functions

Migration helpers (tables/migration.go):
- Add dialectStep(mysqlStmt, pgStmt) helper for dual-dialect DDL steps
  in the withSteps() pattern

All existing migrations continue to work unchanged — they use UpFn
which is the fallback when no dialect-specific function is set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3+4 infrastructure:

server/platform/postgres/:
- errors.go: PG error classification using SQLSTATE codes (23505 unique,
  23503 foreign key, 25006 read-only, 08xxx connection). Works with both
  pgx and lib/pq error types via interface-based code extraction.
- common.go: NewDB() helper for opening pgx/stdlib connections via sqlx.

migrations/tables/migration.go:
- Add migrationHelper interface abstracting schema introspection
  (fkExists, constraintExists, columnExists, columnsExists, tableExists)
- mysqlMigrationHelper implementation using MySQL information_schema
- Package-level functions delegate to defaultMigrationHelper for
  backwards compatibility with all existing migrations

mysql.go:
- dialectForDriver() selects mysqlDialect{} or postgresDialect{} based
  on config.Driver field
- checkAndModifyConfig now accepts "postgres" as valid Driver value
- NewDatastore wires dialect via dialectForDriver(cfg.Driver)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…(Phase 4)

Remove //go:build ignore tag and implement all 16 DialectHelper methods
for PostgreSQL:

SQL translation:
- InsertIgnoreInto/ReplaceInto → "INSERT INTO" (PG uses suffix clauses)
- OnDuplicateKey → "ON CONFLICT (target) DO UPDATE SET" with automatic
  VALUES(col) → EXCLUDED.col regex translation
- OnConflictDoNothing → "ON CONFLICT (target) DO NOTHING"
- GroupConcat → STRING_AGG(expr::text, sep)
- JSONExtract → col->'path' (strips MySQL $.prefix)
- JSONUnquoteExtract → col->>'path'
- JSONBuildObject → jsonb_build_object(k, v, ...)
- FindInSet → needle = ANY(string_to_array(col, ','))
- FullTextMatch → to_tsvector('english', col) @@ plainto_tsquery(...)
- RegexpMatch → col ~ pattern
- GoquDialect → goqu.Dialect("postgres")

Error classification:
- IsDuplicate → SQLSTATE 23505 (unique_violation)
- IsForeignKey → SQLSTATE 23503 (foreign_key_violation)
- IsReadOnly → SQLSTATE 25006 (read_only_sql_transaction)
- IsBadConnection → standard Go driver/io/syscall errors + PG-specific
  connection error messages

Helpers: translateValuesToExcluded() regex, stripDollarDotPrefix()

Tests: dialect_postgres_test.go with 15 SQL generation tests +
translateValuesToExcluded edge cases + stripDollarDotPrefix cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…fication

Two issues found in review:

1. CRITICAL: JSONExtract/JSONUnquoteExtract nested path was wrong.
   MySQL $.mdm.enable_recovery_lock_password traverses nested keys, but
   PG col->'mdm.enable_recovery_lock_password' looks for a single key
   with dots in the name. Fixed: new mysqlPathToPGChain() splits on '.'
   and chains -> operators: col->'mdm'->'enable_recovery_lock_password'.
   JSONUnquoteExtract uses ->> for the final segment only.

2. DRY: postgresDialect error classification duplicated logic from
   server/platform/postgres. Replaced containsSQLState/isStdBadConnection
   with delegation to pg.IsDuplicate/IsForeignKey/IsReadOnly/IsBadConnection
   which uses proper pgx/pq interface-based code extraction.

Also applied staticcheck suggestion: strings.TrimPrefix instead of
HasPrefix+slice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dnplkndll and others added 2 commits March 18, 2026 19:37
Docker Compose:
- postgres_test: PG 16 Alpine with fsync/WAL disabled for fast tests
  (port 5432, configurable via FLEET_POSTGRES_TEST_PORT)
- postgres: PG 16 Alpine development instance (port 5433)

Test infrastructure:
- CreatePostgresDS(t): creates isolated PG test database per test,
  wires postgresDialect, registers pgx/stdlib driver
- postgres_smoke_test.go: validates INSERT IGNORE, upsert, GROUP_CONCAT,
  and JSON operations against real PostgreSQL — all pass

Makefile:
- e2e-reset-db-pg: drop/create PG e2e database + prepare schema
- e2e-serve-pg: start Fleet server against PostgreSQL

Helm chart (charts/fleet/):
- Chart.yaml: add postgresql bitnami subchart dependency (conditional)
- values.yaml: add database.driver field ("mysql" default, "postgres")
- deployment.yaml: emit FLEET_MYSQL_DRIVER env var when driver is set

go.mod: add github.com/jackc/pgx/v5 v5.8.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review fixes:
- Sanitize test database name in CreatePostgresDS to alphanumeric +
  underscore only (prevents SQL injection via adversarial test names)
- Change default FLEET_POSTGRES_TEST_PORT from 5432 → 5434 to avoid
  conflict with local PostgreSQL installations (matched across
  docker-compose.yml, testing_utils.go, and Makefile)
- Add postgresql.enabled: false default in Helm values.yaml (Chart.yaml
  references it via condition but it was undefined)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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