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)