From 5eb4ae3489dae6223d528acfbc479c4eeccd2fde Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Wed, 1 Jul 2026 15:32:53 -0600 Subject: [PATCH] Sort host device_mapping emails deterministically The device_mapping GROUP_CONCAT in the host list and label host list queries had no ORDER BY, so emails came back in an unspecified order that varied between executions. This caused flaky test failures and inconsistent API output. Sort by email, source to match the single-host device mapping query. Claude-Session: https://claude.ai/code/session_01J77VQSSuPptxpoZDrFQxdj --- changes/device-mapping-order | 1 + server/datastore/mysql/hosts.go | 2 +- server/datastore/mysql/labels.go | 2 +- server/service/integration_core_test.go | 10 ++++++---- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 changes/device-mapping-order diff --git a/changes/device-mapping-order b/changes/device-mapping-order new file mode 100644 index 00000000000..d515f3720e1 --- /dev/null +++ b/changes/device-mapping-order @@ -0,0 +1 @@ +- Made the order of a host's `device_mapping` emails deterministic (sorted by email) in host list and label host list responses. diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 9a7a9246c5d..98609f9b04b 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -1167,7 +1167,7 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt // idx_host_emails_host_id_email. sql += fmt.Sprintf(`, COALESCE(( - SELECT CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', he.email, 'source', %s)), ']') + SELECT CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', he.email, 'source', %s) ORDER BY he.email, he.source), ']') FROM host_emails he WHERE he.host_id = h.id ), 'null') as device_mapping diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 4bfa8aadb57..844d9cedd64 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -1234,7 +1234,7 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt deviceMappingJoin := fmt.Sprintf(`LEFT JOIN ( SELECT host_id, - CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', %s)), ']') AS device_mapping + CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', %s) ORDER BY email, source), ']') AS device_mapping FROM host_emails GROUP BY diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 8328a7225ca..f687767c96e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6149,12 +6149,13 @@ func (s *integrationTestSuite) TestListHostsByLabel() { ), ) - // Add device mapping + // Add device mapping, inserted out of order to verify the response is + // sorted by email require.NoError( t, s.ds.ReplaceHostDeviceMapping( context.Background(), host.ID, []*fleet.HostDeviceMapping{ - {HostID: hosts[0].ID, Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, {HostID: hosts[0].ID, Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {HostID: hosts[0].ID, Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, }, fleet.DeviceMappingGoogleChromeProfiles, ), ) @@ -10542,10 +10543,11 @@ func (s *integrationTestSuite) TestHostsReportDownload() { err = s.ds.RecordPolicyQueryExecutions(ctx, hosts[1], map[uint]*bool{pol.ID: new(false)}, time.Now(), false, nil) require.NoError(t, err) - // create some device mappings for host[2] + // create some device mappings for host[2], inserted out of order to verify + // the response is sorted by email err = s.ds.ReplaceHostDeviceMapping(ctx, hosts[2].ID, []*fleet.HostDeviceMapping{ - {HostID: hosts[2].ID, Email: "a@b.c", Source: "google_chrome_profiles"}, {HostID: hosts[2].ID, Email: "b@b.c", Source: "google_chrome_profiles"}, + {HostID: hosts[2].ID, Email: "a@b.c", Source: "google_chrome_profiles"}, }, "google_chrome_profiles") require.NoError(t, err)