From f60d08138937c30c93ae3e6fe68b4bdb525591f9 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 14 Jan 2026 08:32:30 -0500 Subject: [PATCH] Bugfix: Fix query to ignore host_software_installs rows where host is deleted (#38250) --- ...staller-from-inventory-after-wipe-reenroll | 1 + server/datastore/mysql/software.go | 20 +- server/datastore/mysql/software_test.go | 61 +++++ server/service/integration_mdm_dep_test.go | 217 ++++++++++++++++++ 4 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 changes/33815-fix-missing-installer-from-inventory-after-wipe-reenroll diff --git a/changes/33815-fix-missing-installer-from-inventory-after-wipe-reenroll b/changes/33815-fix-missing-installer-from-inventory-after-wipe-reenroll new file mode 100644 index 0000000000..71eecb3659 --- /dev/null +++ b/changes/33815-fix-missing-installer-from-inventory-after-wipe-reenroll @@ -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. diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 44fe1de41d..7ac5b1286c 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -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{}{ diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 3d184cc592..3c74dea0bf 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -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) +} diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 5311b23e1b..163f772955 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -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) +}