Add hash_sha256 field to host installer result and install software activity (#49085)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #
- Adds hash_sha256 field to host installer result and install software
activity so they can be used in combination to cover past activities
with existing installers + all new activities even if their installer
will get deleted

# Checklist for submitter


## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Software installation results now include the installer’s SHA-256 hash
when available.
* Installed-software activity records now include the corresponding
`hash_sha256` value.

* **Bug Fixes**
* Hash values are retained in activity history after an installer is
deleted, while live results correctly show the value as unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jonathan Katz
2026-07-10 12:16:38 -04:00
committed by GitHub
parent 70a3206a58
commit 307f9b3108
7 changed files with 64 additions and 3 deletions
@@ -1999,6 +1999,7 @@ SELECT
COALESCE(st.name, hsi.software_title_name) AS software_title,
hsi.software_title_id,
hsi.software_installer_id,
si.storage_id AS hash_sha256,
COALESCE(hsi.execution_status, '') AS status,
hsi.installer_filename AS software_package,
hsi.user_id AS user_id,
@@ -2014,6 +2015,7 @@ SELECT
FROM
host_software_installs hsi
LEFT JOIN software_titles st ON hsi.software_title_id = st.id
LEFT JOIN software_installers si ON hsi.software_installer_id = si.id
WHERE
hsi.execution_id = :execution_id AND
hsi.uninstall = 0 AND
@@ -2030,6 +2032,7 @@ SELECT
COALESCE(st.name, ua.payload->>'$.software_title_name') AS software_title,
siua.software_title_id,
siua.software_installer_id,
si.storage_id AS hash_sha256,
'pending_install' AS status,
ua.payload->>'$.installer_filename' AS software_package,
ua.user_id AS user_id,
@@ -2048,6 +2051,8 @@ FROM
ON ua.id = siua.upcoming_activity_id
LEFT JOIN software_titles st
ON siua.software_title_id = st.id
LEFT JOIN software_installers si
ON siua.software_installer_id = si.id
WHERE
ua.execution_id = :execution_id AND
ua.activity_type = 'software_install' AND
@@ -922,6 +922,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
t.Run(tc.name, func(t *testing.T) {
// create a host and software installer
swFilename := "file_" + tc.name + ".pkg"
swStorageID := "hash_" + tc.name
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo" + tc.name,
Source: "bar" + tc.name,
@@ -929,6 +930,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
Version: "1.11",
TeamID: &teamID,
Filename: swFilename,
StorageID: swStorageID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
@@ -979,6 +981,9 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
res, err = ds.GetSoftwareInstallResults(ctx, installUUID)
require.NoError(t, err)
require.Equal(t, swFilename, res.SoftwarePackage)
// hash comes from the installer, which still exists here
require.NotNil(t, res.HashSHA256)
require.Equal(t, swStorageID, *res.HashSHA256)
// delete installer to confirm that we can still access the install record (unless pending)
err = ds.DeleteSoftwareInstaller(ctx, installerID)
@@ -1006,6 +1011,8 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
require.Equal(t, installUUID, res.InstallUUID)
require.Equal(t, tc.expectedStatus, res.Status)
require.Equal(t, swFilename, res.SoftwarePackage)
// installer was deleted, so its hash is no longer available
require.Nil(t, res.HashSHA256)
require.Equal(t, host.ID, res.HostID)
require.Equal(t, tc.preInstallQueryOutput, res.PreInstallQueryOutput)
require.Equal(t, tc.postInstallScriptOutput, res.PostInstallScriptOutput)
+1
View File
@@ -1116,6 +1116,7 @@ type ActivityTypeInstalledSoftware struct {
HostDisplayName string `json:"host_display_name"`
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`
HashSHA256 *string `json:"hash_sha256,omitempty"`
SelfService bool `json:"self_service"`
InstallUUID string `json:"install_uuid"`
Status string `json:"status"`
+3
View File
@@ -449,6 +449,9 @@ type HostSoftwareInstallerResult struct {
SoftwareInstallerID *uint `json:"-" db:"software_installer_id"`
// SoftwarePackage is the name of the software installer package.
SoftwarePackage string `json:"software_package" db:"software_package"`
// HashSHA256 is the SHA256 hash of the software installer package. It is
// nil when the installer has been deleted from the server.
HashSHA256 *string `json:"hash_sha256" db:"hash_sha256"`
// Source is the osquery source for this software (e.g., "sh_packages", "ps1_packages").
Source *string `json:"source" db:"source"`
// HostID is the ID of the host.
+43 -1
View File
@@ -13271,6 +13271,12 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
},
}, http.StatusNoContent)
// while the installer exists, the install results carry its hash
beforeDelete, err := s.ds.GetSoftwareInstallResults(context.Background(), installUUID)
require.NoError(t, err)
require.NotNil(t, beforeDelete.HashSHA256)
wantHash := *beforeDelete.HashSHA256
_ = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", fleet.OrbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
@@ -13282,6 +13288,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": "%s", "team_id": %d, "fleet_name": "%s", "fleet_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
// the installed_software activity keeps its hash after the installer is deleted
var hostActivities listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", hostInTeam.ID), nil, http.StatusOK, &hostActivities)
require.Len(t, hostActivities.Activities, 1)
require.Equal(t, fleet.ActivityTypeInstalledSoftware{}.ActivityName(), hostActivities.Activities[0].Type)
var installedActivity fleet.ActivityTypeInstalledSoftware
require.NoError(t, json.Unmarshal([]byte(*hostActivities.Activities[0].Details), &installedActivity))
require.NotNil(t, installedActivity.HashSHA256)
require.Equal(t, wantHash, *installedActivity.HashSHA256)
// but the live install results lose it
afterDelete, err := s.ds.GetSoftwareInstallResults(context.Background(), installUUID)
require.NoError(t, err)
require.Nil(t, afterDelete.HashSHA256)
// download the installer, not found anymore
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", *payload.TeamID))
})
@@ -16239,6 +16260,12 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
assert.Greater(t, time.Now(), resp.Results.CreatedAt)
}
installHash := func(installUUID string) *string {
res, err := s.ds.GetSoftwareInstallResults(ctx, installUUID)
require.NoError(t, err)
return res.HashSHA256
}
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
@@ -16264,6 +16291,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
InstallUUID: installUUIDs[0],
Status: string(fleet.SoftwareInstallFailed),
Source: new("deb_packages"),
HashSHA256: installHash(installUUIDs[0]),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@@ -16289,6 +16317,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
InstallUUID: installUUIDs[1],
Status: string(fleet.SoftwareInstallFailed),
Source: new("deb_packages"),
HashSHA256: installHash(installUUIDs[1]),
}
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@@ -16320,6 +16349,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
InstallUUID: installUUIDs[2],
Status: string(fleet.SoftwareInstalled),
Source: new("deb_packages"),
HashSHA256: installHash(installUUIDs[2]),
}
lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@@ -16357,6 +16387,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
InstallUUID: installUUIDs[2],
Status: string(fleet.SoftwareInstallFailed),
Source: new("deb_packages"),
HashSHA256: installHash(installUUIDs[2]),
}
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@@ -19043,11 +19074,18 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent)
var host1InstallerHash string
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &host1InstallerHash,
`SELECT si.storage_id FROM host_software_installs hsi JOIN software_installers si ON hsi.software_installer_id = si.id WHERE hsi.execution_id = ?`,
host1LastInstall.ExecutionID)
})
s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
"host_id": %d,
"host_display_name": "%s",
"software_title": "%s",
"software_package": "%s",
"hash_sha256": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
@@ -19055,7 +19093,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
"policy_id": %d,
"policy_name": "%s",
"from_setup_experience": false
}`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp", "dummy_installer.pkg", host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0)
}`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp", "dummy_installer.pkg", host1InstallerHash, host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0)
var activityCount int
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
@@ -21189,6 +21227,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() {
InstallUUID: installUUID,
Status: string(fleet.SoftwareInstallFailed),
Source: new("rpm_packages"),
HashSHA256: resp.Results.HashSHA256,
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
}
@@ -22396,6 +22435,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerOrbitDownloadFailu
require.Equal(t, scriptExecID, listUpcomingAct.Activities[0].UUID)
// past activity is created for the software install
installRes, err := s.ds.GetSoftwareInstallResults(context.Background(), swInstallExecID)
require.NoError(t, err)
wantAct := fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
@@ -22404,6 +22445,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerOrbitDownloadFailu
InstallUUID: swInstallExecID,
Status: string(fleet.SoftwareInstalled),
Source: new("deb_packages"),
HashSHA256: installRes.HashSHA256,
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
}
@@ -498,6 +498,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
"host_display_name": "%s",
"software_title": "%s",
"software_package": "%s",
"hash_sha256": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
@@ -506,7 +507,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
"policy_name": null,
"from_setup_experience": true
}
`, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, installUUID)
`, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.StorageID, installUUID)
s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true))
@@ -931,6 +932,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba
"host_display_name": "%s",
"software_title": "1Password",
"software_package": "%s",
"hash_sha256": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
@@ -939,7 +941,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba
"policy_name": null,
"from_setup_experience": true
}
`, enrolledHost.ID, getHostResp.Host.DisplayName, titleDetail.SoftwareTitle.SoftwarePackage.Name, installUUID)
`, enrolledHost.ID, getHostResp.Host.DisplayName, titleDetail.SoftwareTitle.SoftwarePackage.Name, titleDetail.SoftwareTitle.SoftwarePackage.StorageID, installUUID)
s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true))
}
+1
View File
@@ -1710,6 +1710,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
HostDisplayName: host.DisplayName(),
SoftwareTitle: hsi.SoftwareTitle,
SoftwarePackage: hsi.SoftwarePackage,
HashSHA256: hsi.HashSHA256,
InstallUUID: result.InstallUUID,
Status: string(status),
Source: hsi.Source,