From bcd55ec3ffa739c6a4929c1453dd2e114f354fe5 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 8 Jul 2026 07:59:44 -0400 Subject: [PATCH 1/4] fix(pg): rebind: cast $N * INTERVAL multiplier to float8, not timestamptz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reParamBeforeInterval stamped ::timestamptz on every $N before [-+*] INTERVAL, so a param that scales an interval — the SCEP expiry filter's (? * INTERVAL '1 day') — became timestamptz * interval and failed with SQLSTATE 42883 on every renew_scep_certificates cron run. Split the regex: +/- keep ::timestamptz (the param is a point in time), * now gets ::float8, matching the INTERVAL ? UNIT rewrite convention. Unit tests cover all three operators. Claude-Session: https://claude.ai/code/session_01VLrsW7aZYZVcJgbpi44gzU --- server/platform/postgres/rebind_driver.go | 5 ++++- server/platform/postgres/rebind_driver_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/platform/postgres/rebind_driver.go b/server/platform/postgres/rebind_driver.go index 3517695b287..2b917556e2c 100644 --- a/server/platform/postgres/rebind_driver.go +++ b/server/platform/postgres/rebind_driver.go @@ -68,7 +68,9 @@ var ( // MySQL: INSERT INTO table () VALUES () — empty column/value lists for auto-increment-only inserts reEmptyValues = regexp.MustCompile(`(?i)(INSERT\s+INTO\s+\S+\s+)\(\s*\)\s*VALUES\s*\(\s*\)`) // PG can't infer $N type in interval arithmetic; cast to timestamptz - reParamBeforeInterval = regexp.MustCompile(`(\$\d+)\s+([-+*]\s*INTERVAL\b)`) + reParamBeforeInterval = regexp.MustCompile(`(\$\d+)\s+([-+]\s*INTERVAL\b)`) + // $N * INTERVAL scales the interval, so the param is a number, not a timestamp + reParamTimesInterval = regexp.MustCompile(`(\$\d+)\s*(\*\s*INTERVAL\b)`) // JSON boolean comparison: MySQL ->> on JSON true returns '1', PG returns 'true'. // Match: COALESCE(, '0') = '1' → COALESCE(, '0') IN ('1', 'true') reJSONBoolCoalesce = regexp.MustCompile(`COALESCE\(([^)]+->>'[^']+'),\s*'0'\)\s*=\s*'1'`) @@ -694,6 +696,7 @@ func rebindQuery(query string) string { // PG can't infer the type of $N when used in interval arithmetic ($N - INTERVAL, $N + INTERVAL). // Cast to timestamptz so the operator resolves correctly. result = reParamBeforeInterval.ReplaceAllString(result, "${1}::timestamptz ${2}") + result = reParamTimesInterval.ReplaceAllString(result, "${1}::float8 ${2}") return result } diff --git a/server/platform/postgres/rebind_driver_test.go b/server/platform/postgres/rebind_driver_test.go index c0c00803eb2..ef0572db3fc 100644 --- a/server/platform/postgres/rebind_driver_test.go +++ b/server/platform/postgres/rebind_driver_test.go @@ -301,6 +301,21 @@ func TestRewriteIntervalPlaceholder(t *testing.T) { in: "a >= NOW() - INTERVAL ? SECOND AND b <= NOW() + INTERVAL ? MINUTE", want: "a >= NOW() - ($1::float8 * INTERVAL '1 second') AND b <= NOW() + ($2::float8 * INTERVAL '1 minute')", }, + { + name: "placeholder plus INTERVAL gets timestamptz cast", + in: "expires_at <= ? + INTERVAL '1 hour'", + want: "expires_at <= $1::timestamptz + INTERVAL '1 hour'", + }, + { + name: "placeholder minus INTERVAL gets timestamptz cast", + in: "seen_at >= ? - INTERVAL '30 minutes'", + want: "seen_at >= $1::timestamptz - INTERVAL '30 minutes'", + }, + { + name: "placeholder times INTERVAL gets float8 cast, not timestamptz", + in: "cert_not_valid_after <= CURRENT_DATE + (? * INTERVAL '1 day')", + want: "cert_not_valid_after <= CURRENT_DATE + ($1::float8 * INTERVAL '1 day')", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From 10f69293875d5e3dacefe374cc7fdff11ed432db Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 8 Jul 2026 07:59:44 -0400 Subject: [PATCH 2/4] fix(pg): SCEP expiry query: satisfy PostgreSQL GROUP BY strictness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ne.enrolled_from_migration and ne.type are selected but were not grouped; MySQL tolerates that, PG rejects it (SQLSTATE 42803) — the second failure hiding behind the interval-cast error in the same renew_scep_certificates query. Both columns are functionally dependent on the existing group key (host_uuid = ne.id), so adding them changes no MySQL semantics. Claude-Session: https://claude.ai/code/session_01VLrsW7aZYZVcJgbpi44gzU --- server/datastore/mysql/mdm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index fa4a9e54525..95a406543ae 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1999,7 +1999,7 @@ WHERE AND ncaa.renew_command_uuid IS NULL AND ne.enabled = true GROUP BY - host_uuid, ncaa.sha256, ncaa.cert_not_valid_after + host_uuid, ncaa.sha256, ncaa.cert_not_valid_after, ne.enrolled_from_migration, ne.type ORDER BY cert_not_valid_after ASC LIMIT ?`, expiryDays, limit) From 1a13c33361b3a1c7916a951d8eacbd2f37887554 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 8 Jul 2026 07:59:44 -0400 Subject: [PATCH 3/4] fix(pg): Windows MDM cleanup and re-enrollment DELETEs: cross-dialect syntax DELETE FROM ... is MySQL multi-table syntax and fails to parse on PostgreSQL (SQLSTATE 42601). Three statements were affected: - CleanupWindowsMDMPendingDeleteProfiles (red in cron_stats every cleanups_then_aggregation run) - MDMWindowsDeleteEnrolledDeviceOnReenrollment's setup-experience and upcoming-activities deletes (broke Windows re-enrollment on PG; latent because the flow is not in the TestPostgres CI suite) Rewritten to the cross-dialect correlated NOT EXISTS / IN (subquery) forms already used by CleanupHostIssues. Claude-Session: https://claude.ai/code/session_01VLrsW7aZYZVcJgbpi44gzU --- server/datastore/mysql/microsoft_mdm.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index a730d617996..aee567dc435 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -446,12 +446,17 @@ func (ds *Datastore) MDMWindowsDeleteEnrolledDeviceOnReenrollment(ctx context.Co delActionsStmt = "DELETE FROM host_mdm_actions WHERE host_id = (SELECT id FROM hosts WHERE uuid = ? LIMIT 1)" delProfilesStmt = "DELETE FROM host_mdm_windows_profiles WHERE host_uuid = ?" // setup_experience_status_results.host_uuid is keyed by fleet.HostUUIDForSetupExperience; for Windows that's the - // host's OsqueryHostID, NOT the Fleet host UUID stored on the MDM enrollment. Resolve via JOIN so we delete by - // whichever identifier matches (works for both shapes). - delSetupExpStmt = `DELETE ser FROM setup_experience_status_results ser - JOIN hosts h ON ser.host_uuid = h.osquery_host_id OR ser.host_uuid = h.uuid - WHERE h.uuid = ?` - delUpcomingStmt = `DELETE ua FROM upcoming_activities ua JOIN hosts h ON h.id = ua.host_id WHERE h.uuid = ?` + // host's OsqueryHostID, NOT the Fleet host UUID stored on the MDM enrollment. Resolve via the hosts table so we + // delete by whichever identifier matches (works for both shapes). + // Cross-dialect: avoid MySQL-only "DELETE alias FROM ... JOIN" syntax. + delSetupExpStmt = `DELETE FROM setup_experience_status_results + WHERE EXISTS ( + SELECT 1 FROM hosts h + WHERE (setup_experience_status_results.host_uuid = h.osquery_host_id + OR setup_experience_status_results.host_uuid = h.uuid) + AND h.uuid = ? + )` + delUpcomingStmt = `DELETE FROM upcoming_activities WHERE host_id IN (SELECT id FROM hosts WHERE uuid = ?)` ) return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { @@ -3777,10 +3782,12 @@ LIMIT ?` // needs: a host that was offline when the profile was deleted keeps its host-profile row, which keeps the content alive until the // removal finally delivers. The NOT EXISTS is an index probe via host_mdm_windows_profiles(profile_uuid). func (ds *Datastore) CleanupWindowsMDMPendingDeleteProfiles(ctx context.Context) error { + // Cross-dialect: avoid MySQL-only "DELETE alias FROM table alias" syntax. const stmt = ` -DELETE pd FROM mdm_windows_configuration_profiles_pending_delete pd +DELETE FROM mdm_windows_configuration_profiles_pending_delete WHERE NOT EXISTS ( - SELECT 1 FROM host_mdm_windows_profiles hmwp WHERE hmwp.profile_uuid = pd.profile_uuid + SELECT 1 FROM host_mdm_windows_profiles hmwp + WHERE hmwp.profile_uuid = mdm_windows_configuration_profiles_pending_delete.profile_uuid )` if _, err := ds.writer(ctx).ExecContext(ctx, stmt); err != nil { return ctxerr.Wrap(ctx, err, "cleanup windows mdm pending-delete profiles") From 9796f6464e689d9ff8e3074dad93d75db3c8734b Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 8 Jul 2026 07:59:45 -0400 Subject: [PATCH 4/4] test(pg): regression-cover previously broken MDM statements in the PG suite The renew_scep_certificates query, pending-delete GC, and Windows re-enrollment cleanup were all PG-broken yet green in CI because none of them run under -run TestPostgres. Execute each real statement against PG so a dialect regression fails the gate instead of shipping. This test caught the GROUP BY strictness bug fixed earlier in this branch. Claude-Session: https://claude.ai/code/session_01VLrsW7aZYZVcJgbpi44gzU --- server/datastore/mysql/postgres_smoke_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/server/datastore/mysql/postgres_smoke_test.go b/server/datastore/mysql/postgres_smoke_test.go index ce7ce79572d..a9abad0a7f3 100644 --- a/server/datastore/mysql/postgres_smoke_test.go +++ b/server/datastore/mysql/postgres_smoke_test.go @@ -555,3 +555,44 @@ func TestPostgresHostSoftwareUpdate(t *testing.T) { assert.Empty(t, host.Software, "host inventory should be empty") }) } + +// TestPostgresMDMCleanupQueries is regression coverage for MDM statements that +// previously used MySQL-only SQL and broke at runtime on PostgreSQL (the +// cleanups_then_aggregation cron and Windows re-enrollment). Each subtest +// executes the real statement against PG; a dialect regression fails here +// instead of shipping silently (these paths are not otherwise in the +// TestPostgres* suite). +func TestPostgresMDMCleanupQueries(t *testing.T) { + ds := CreatePostgresDS(t) + ctx := context.Background() + + t.Run("GetHostCertAssociationsToExpire", func(t *testing.T) { + // renew_scep_certificates cron: the expiry filter multiplies a bound + // param by INTERVAL '1 day' and must not be cast to timestamptz. + _, err := ds.GetHostCertAssociationsToExpire(ctx, 30, 100) + require.NoError(t, err, "GetHostCertAssociationsToExpire") + }) + + t.Run("CleanupWindowsMDMPendingDeleteProfiles", func(t *testing.T) { + require.NoError(t, ds.CleanupWindowsMDMPendingDeleteProfiles(ctx), + "CleanupWindowsMDMPendingDeleteProfiles") + }) + + t.Run("MDMWindowsDeleteEnrolledDeviceOnReenrollment", func(t *testing.T) { + device := &fleet.MDMWindowsEnrolledDevice{ + MDMDeviceID: "pg-mdm-device-1", + MDMHardwareID: "pg-mdm-hwid-1-0123456789012345678901234567890123456789", + MDMDeviceState: "2", + MDMDeviceType: "CIMClient_Windows", + MDMDeviceName: "PG-TEST-DESKTOP", + MDMEnrollType: "ProgrammaticEnrollment", + MDMEnrollUserID: "", + MDMEnrollProtoVersion: "5.0", + MDMEnrollClientVersion: "10.0.19045.2965", + MDMNotInOOBE: false, + } + require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, device), "insert enrolled device") + require.NoError(t, ds.MDMWindowsDeleteEnrolledDeviceOnReenrollment(ctx, device.MDMHardwareID), + "MDMWindowsDeleteEnrolledDeviceOnReenrollment") + }) +}