diff --git a/changes/43659-script-only-hash-ref-preserves-install-script b/changes/43659-script-only-hash-ref-preserves-install-script new file mode 100644 index 0000000000..909043d62c --- /dev/null +++ b/changes/43659-script-only-hash-ref-preserves-install-script @@ -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. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 91a1310e1c..9c46aef87a 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -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 diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index b9b47d8a85..ee02e035eb 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -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 diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 8dab152cd0..b6d47bce05 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -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 { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 41e95b71b2..dae6f17600 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -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) +}