Bugfix: Fix query to ignore host_software_installs rows where host is deleted (#38250)

This commit is contained in:
Martin Angers
2026-01-14 08:32:30 -05:00
committed by GitHub
parent 318b6d75dd
commit f60d081389
4 changed files with 290 additions and 9 deletions
@@ -0,0 +1 @@
- Fixed a bug where installed software would not show up in the software inventory of an ADE-enrolled macOS host after a wipe and a re-enrollment.
+11 -9
View File
@@ -3170,13 +3170,13 @@ func hostSoftwareInstalls(ds *Datastore, ctx context.Context, hostID uint) ([]*h
hsi.software_installer_id = hsi2.software_installer_id AND
hsi.uninstall = hsi2.uninstall AND
hsi2.removed = 0 AND
hsi2.canceled = 0 AND
hsi2.canceled = 0 AND
hsi2.host_deleted_at IS NULL AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi.host_id = ? AND
hsi.removed = 0 AND
hsi.canceled = 0 AND
hsi.canceled = 0 AND
hsi.uninstall = 0 AND
hsi.host_deleted_at IS NULL AND
hsi2.id IS NULL AND
@@ -3250,14 +3250,14 @@ func hostSoftwareUninstalls(ds *Datastore, ctx context.Context, hostID uint) ([]
hsi.software_installer_id = hsi2.software_installer_id AND
hsi.uninstall = hsi2.uninstall AND
hsi2.removed = 0 AND
hsi2.canceled = 0 AND
hsi2.canceled = 0 AND
hsi2.host_deleted_at IS NULL AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi.host_id = ? AND
hsi.removed = 0 AND
hsi.uninstall = 1 AND
hsi.canceled = 0 AND
hsi.canceled = 0 AND
hsi.host_deleted_at IS NULL AND
hsi2.id IS NULL AND
NOT EXISTS (
@@ -4511,7 +4511,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
hsi.host_id = :host_id AND
hsi.software_installer_id = si.id AND
hsi.removed = 0 AND
hsi.canceled = 0
hsi.canceled = 0 AND
hsi.host_deleted_at IS NULL
) AND
-- sofware install/uninstall is not upcoming on host
NOT EXISTS (
@@ -5933,6 +5934,7 @@ func (ds *Datastore) CountHostSoftwareInstallAttempts(ctx context.Context, hostI
AND policy_id = ?
AND removed = 0
AND canceled = 0
AND host_deleted_at IS NULL
AND (attempt_number > 0 OR attempt_number IS NULL)
`, hostID, softwareInstallerID, policyID)
if err != nil {
@@ -6061,7 +6063,7 @@ SELECT
FROM software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
INNER JOIN host_software_installs hsi ON hsi.host_id = :host_id AND hsi.software_installer_id = si.id
WHERE hsi.removed = 0 AND hsi.canceled = 0 AND hsi.status = :software_status_installed
WHERE hsi.removed = 0 AND hsi.canceled = 0 AND hsi.host_deleted_at IS NULL AND hsi.status = :software_status_installed
UNION
@@ -6077,9 +6079,9 @@ FROM software_titles st
INNER JOIN vpp_apps vap ON vap.title_id = st.id
INNER JOIN host_vpp_software_installs hvsi ON hvsi.host_id = :host_id AND hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform
LEFT JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
WHERE
hvsi.removed = 0 AND
hvsi.canceled = 0 AND
WHERE
hvsi.removed = 0 AND
hvsi.canceled = 0 AND
(ncr.status = :mdm_status_acknowledged OR hvsi.verification_at IS NOT NULL)
`
selectStmt, args, err := sqlx.Named(stmt, map[string]interface{}{
+61
View File
@@ -106,6 +106,7 @@ func TestSoftware(t *testing.T) {
{"ListHostSoftwareAndroidVPPAppMatching", testListHostSoftwareAndroidVPPAppMatching},
{"CountHostSoftwareInstallAttempts", testCountHostSoftwareInstallAttempts},
{"ListSoftwareVersionsSearchByTitleName", testListSoftwareVersionsSearchByTitleName},
{"ListSoftwareInventoryDeletedHost", testListSoftwareInventoryDeletedHost},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -10672,3 +10673,63 @@ func testListSoftwareVersionsSearchByTitleName(t *testing.T, ds *Datastore) {
require.Len(t, software, 1, "Search by software name should still work")
assert.Equal(t, "Office Runtime Libraries", software[0].Name)
}
// This test verifies the fix for https://github.com/fleetdm/fleet/issues/33815
func testListSoftwareInventoryDeletedHost(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
user := test.NewUser(t, ds, "User", "test@example.com", true)
tfr, err := fleet.NewTempFileReader(strings.NewReader("content"), t.TempDir)
require.NoError(t, err)
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "echo 'installing'",
InstallerFile: tfr,
StorageID: "storage1",
Filename: "installer.pkg",
Title: "Software",
Version: "1.0",
Source: "apps",
UserID: user.ID,
Platform: host.Platform,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// install the software on the host
installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: installUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
OnlyAvailableForInstall: true,
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "Software", software[0].Name)
require.Equal(t, titleID, software[0].ID)
err = ds.DeleteHost(ctx, host.ID)
require.NoError(t, err)
// it should still show up as available for install (still part of the inventory
// and the datastore layer does not check if host exists)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "Software", software[0].Name)
require.Equal(t, titleID, software[0].ID)
}
+217
View File
@@ -2911,3 +2911,220 @@ func (s *integrationMDMTestSuite) TestStickyMDMTeamEnrollment() {
})
}
}
// This test verifies the fix for https://github.com/fleetdm/fleet/issues/33815
func (s *integrationMDMTestSuite) TestSoftwareInventoryForADEMacOSAfterWipeAndReenroll() {
t := s.T()
s.enableABM(t.Name())
s.setSkipWorkerJobs(t)
ctx := t.Context()
user, err := s.ds.UserByEmail(context.Background(), "admin1@example.com")
require.NoError(t, err)
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
profileAssignmentReqs := []profileAssignmentReq{}
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
require.NoError(t, err)
case "/server/devices":
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
require.NoError(t, err)
case "/devices/sync":
err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"})
require.NoError(t, err)
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var prof profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &prof))
profileAssignmentReqs = append(profileAssignmentReqs, prof)
var resp godep.ProfileResponse
resp.ProfileUUID = prof.ProfileUUID
resp.Devices = make(map[string]string, len(prof.Devices))
for _, device := range prof.Devices {
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
}
err = encoder.Encode(resp)
require.NoError(t, err)
default:
_, _ = w.Write([]byte(`{}`))
}
}))
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
performHostEnroll := func() *mdmtest.TestAppleMDMClient {
// Enroll the host via ADE
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = devices[0].SerialNumber
err = mdmDevice.Enroll()
require.NoError(t, err)
// Simulate an osquery enrollment too
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: t.Name()}},
},
}, http.StatusOK, &applyResp)
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&contract.EnrollOsqueryAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: mdmDevice.UUID,
})
require.NoError(t, err)
var enrollResp contract.EnrollOsqueryAgentResponse
hres := s.DoRawNoAuth("POST", "/api/osquery/enroll", j, http.StatusOK)
require.NoError(t, json.NewDecoder(hres.Body).Decode(&enrollResp))
require.NotEmpty(t, enrollResp.NodeKey)
return mdmDevice
}
mdmDevice := performHostEnroll()
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
h := listHostsRes.Hosts[0]
// ensure the host has an orbit key (so it doesn't fail with "does not have fleetd")
h.OrbitNodeKey = ptr.String("some-orbit-key")
err = s.ds.UpdateHost(ctx, h.Host)
require.NoError(t, err)
s.runDEPSchedule()
// run the worker to process the DEP enroll request
s.runWorker()
// run the cron to assign configuration profiles
s.awaitTriggerProfileSchedule(t)
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
// add a couple software installers to "no team"
tfr1, err := fleet.NewTempFileReader(strings.NewReader("installer1"), t.TempDir)
require.NoError(t, err)
installerPayload1 := fleet.UploadSoftwareInstallerPayload{
InstallScript: "installer1",
PreInstallQuery: "SELECT 1",
InstallerFile: tfr1,
StorageID: "installer1",
Filename: "installer1.pkg",
Title: "installer1",
Version: "1.0",
Source: "apps",
UserID: user.ID,
TeamID: nil,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
_, titleID1, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &installerPayload1)
require.NoError(t, err)
tfr2, err := fleet.NewTempFileReader(strings.NewReader("installer2"), t.TempDir)
require.NoError(t, err)
installerPayload2 := fleet.UploadSoftwareInstallerPayload{
InstallScript: "installer2",
PreInstallQuery: "SELECT 1",
InstallerFile: tfr2,
StorageID: "installer2",
Title: "installer2",
Version: "2.0",
Source: "apps",
UserID: user.ID,
TeamID: nil,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
_, titleID2, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &installerPayload2)
require.NoError(t, err)
// list host software inventory, both installers are listed
getHostSw := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 2)
require.Equal(t, titleID1, getHostSw.Software[0].ID)
require.Equal(t, installerPayload1.Title, getHostSw.Software[0].Name)
require.Equal(t, titleID2, getHostSw.Software[1].ID)
require.Equal(t, installerPayload2.Title, getHostSw.Software[1].Name)
// install the first installer on the host
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID1), installSoftwareRequest{}, http.StatusAccepted)
installUUID := getLatestSoftwareInstallExecID(t, s.ds, h.ID)
// process installation successfully
s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{
OrbitNodeKey: *h.OrbitNodeKey,
HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{
HostID: h.ID,
InstallUUID: installUUID,
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("done"),
},
}, http.StatusNoContent)
// wipe the host
var wipeResp wipeHostResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", h.ID), nil, http.StatusOK, &wipeResp)
require.Equal(t, fleet.PendingActionWipe, wipeResp.PendingAction)
// simulate a successful MDM result for the wipe command
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "EraseDevice", cmd.Command.RequestType)
_, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// refresh the host's status, it is wiped
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// delete the host record (will not really delete it as it is in ABM)
var delResp deleteHostResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &delResp)
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, h.ID, listHostsRes.Hosts[0].ID)
// re-enroll the host
performHostEnroll()
// Sofware inventory should list both installers
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 2)
require.Equal(t, titleID1, getHostSw.Software[0].ID)
require.Equal(t, installerPayload1.Title, getHostSw.Software[0].Name)
require.Equal(t, titleID2, getHostSw.Software[1].ID)
require.Equal(t, installerPayload2.Title, getHostSw.Software[1].Name)
}