Fix script-only packages not setting install script file (#44299)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43659

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## 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

* **Bug Fixes**
* Preserves install scripts for script-only software installers when
using hash-based references in GitOps, preventing self-service installs
from silently no‑opping.
* **Tests**
* Added an integration regression test to verify batch installer
resolution by hash preserves uploaded install script contents.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jonathan Katz
2026-04-28 13:37:03 -04:00
committed by GitHub
parent 384789d178
commit 51dca83dec
5 changed files with 103 additions and 22 deletions
@@ -0,0 +1 @@
- Fixed a bug where applying GitOps to a script-only package by `hash_sha256` reference would wipe the install script, causing self-service installs to silently no-op.
+26 -6
View File
@@ -2539,10 +2539,14 @@ func (svc *Service) softwareBatchUpload(
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
fillSoftwareInstallerPayloadFromExisting(installer, foundInstaller, p.SHA256)
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, foundInstaller, p.SHA256); err != nil {
return err
}
for _, extraInstaller := range foundInstallers[1:] {
extraPayload := extraInstallerBase
fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256)
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, &extraPayload, extraInstaller, p.SHA256); err != nil {
return err
}
extraInstallers = append(extraInstallers, &extraPayload)
}
@@ -2583,10 +2587,14 @@ func (svc *Service) softwareBatchUpload(
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
fillSoftwareInstallerPayloadFromExisting(installer, teamInstaller, p.SHA256)
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, teamInstaller, p.SHA256); err != nil {
return err
}
for _, extraInstaller := range teamInstallers[1:] {
extraPayload := extraInstallerBase
fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256)
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, &extraPayload, extraInstaller, p.SHA256); err != nil {
return err
}
extraInstallers = append(extraInstallers, &extraPayload)
}
@@ -2690,7 +2698,9 @@ func (svc *Service) softwareBatchUpload(
if resp != nil && resp.StatusCode == http.StatusNotModified && existingForCache != nil {
bytesExist, existErr := svc.softwareInstallStore.Exists(ctx, existingForCache.StorageID)
if existErr == nil && bytesExist {
fillSoftwareInstallerPayloadFromExisting(installer, existingForCache, existingForCache.StorageID)
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, existingForCache, existingForCache.StorageID); err != nil {
return err
}
installer.HTTPETag = existingForCache.HTTPETag
// Propagate the existing hash so FMA hydration below
// doesn't try to recompute it from the (nil) file
@@ -2975,7 +2985,7 @@ func (svc *Service) softwareBatchUpload(
// anymore, so that's intentionally skipped.
}
func fillSoftwareInstallerPayloadFromExisting(payload *fleet.UploadSoftwareInstallerPayload, existing *fleet.ExistingSoftwareInstaller, sha256Hash string) {
func (svc *Service) fillSoftwareInstallerPayloadFromExisting(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, existing *fleet.ExistingSoftwareInstaller, sha256Hash string) error {
payload.Extension = existing.Extension
payload.Filename = existing.Filename
payload.Version = existing.Version
@@ -2987,6 +2997,16 @@ func fillSoftwareInstallerPayloadFromExisting(payload *fleet.UploadSoftwareInsta
payload.Title = existing.Title
payload.StorageID = sha256Hash
payload.PackageIDs = existing.PackageIDs
if fleet.IsScriptPackage(existing.Extension) {
contents, err := svc.ds.GetAnyScriptContents(ctx, existing.InstallScriptContentID)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetch install script for hash-matched script package")
}
payload.InstallScript = string(contents)
}
return nil
}
// validETag checks if an ETag value is a strong ETag per RFC 7232
@@ -3590,7 +3590,8 @@ SELECT
st.source,
st.bundle_identifier,
st.name AS title,
si.package_ids
si.package_ids,
si.install_script_content_id
FROM
software_installers si
JOIN software_titles st ON si.title_id = st.id
@@ -3611,7 +3612,8 @@ SELECT
st.source,
st.bundle_identifier,
st.name AS title,
'' AS package_ids
'' AS package_ids,
0 AS install_script_content_id
FROM
in_house_apps iha
JOIN software_titles st ON iha.title_id = st.id
@@ -3666,7 +3668,8 @@ SELECT
st.bundle_identifier AS bundle_identifier,
st.name AS title,
si.package_ids AS package_ids,
si.http_etag AS http_etag
si.http_etag AS http_etag,
si.install_script_content_id AS install_script_content_id
FROM
software_installers si
JOIN software_titles st ON si.title_id = st.id
+14 -13
View File
@@ -586,19 +586,20 @@ func (p UploadSoftwareInstallerPayload) GetUpgradeCodeForDB() *string {
}
type ExistingSoftwareInstaller struct {
InstallerID uint `db:"installer_id"`
TeamID *uint `db:"team_id"`
Filename string `db:"filename"`
Extension string `db:"extension"`
Version string `db:"version"`
Platform string `db:"platform"`
Source string `db:"source"`
BundleIdentifier *string `db:"bundle_identifier"`
Title string `db:"title"`
PackageIDList string `db:"package_ids"`
PackageIDs []string
StorageID string `db:"storage_id"`
HTTPETag *string `db:"http_etag"`
InstallerID uint `db:"installer_id"`
TeamID *uint `db:"team_id"`
Filename string `db:"filename"`
Extension string `db:"extension"`
Version string `db:"version"`
Platform string `db:"platform"`
Source string `db:"source"`
BundleIdentifier *string `db:"bundle_identifier"`
Title string `db:"title"`
PackageIDList string `db:"package_ids"`
PackageIDs []string
StorageID string `db:"storage_id"`
HTTPETag *string `db:"http_etag"`
InstallScriptContentID uint `db:"install_script_content_id"`
}
type UpdateSoftwareInstallerPayload struct {
@@ -29132,3 +29132,59 @@ func (s *integrationEnterpriseTestSuite) TestAPIOnlyUserEndpointMiddleware() {
s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusOK)
})
}
// Regression test for fleet#43659.
func (s *integrationEnterpriseTestSuite) TestBatchSetInstallersScriptByHash() {
t := s.T()
ctx := context.Background()
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name(), Description: "desc"})
require.NoError(t, err)
scriptBytes, err := os.ReadFile(filepath.Join("testdata", "software-installers", "script.sh"))
require.NoError(t, err)
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{
TeamID: &tm.ID,
Filename: "script.sh",
SelfService: true,
}, http.StatusOK, "")
var listResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listResp,
"team_id", fmt.Sprintf("%d", tm.ID), "available_for_install", "true")
var titleID uint
for _, sw := range listResp.SoftwareTitles {
if sw.SoftwarePackage != nil && sw.SoftwarePackage.Name == "script.sh" {
titleID = sw.ID
break
}
}
require.NotZero(t, titleID)
var storageID string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &storageID,
"SELECT storage_id FROM software_installers WHERE title_id = ? AND global_or_team_id = ?",
titleID, tm.ID)
})
require.NotEmpty(t, storageID)
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch",
batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{SHA256: storageID}}},
http.StatusAccepted, &batchResponse, "team_name", tm.Name)
waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, tm.Name, batchResponse.RequestUUID)
var installScript string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installScript, `
SELECT sc.contents
FROM software_installers si
JOIN script_contents sc ON sc.id = si.install_script_content_id
WHERE si.title_id = ? AND si.global_or_team_id = ?`,
titleID, tm.ID)
})
require.Equal(t, string(scriptBytes), installScript)
}