diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index ba16742540a..21efddf3cae 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -1015,6 +1015,7 @@ spec: id: 0 name: foo software_package: null + packages: null source: chrome_extensions extension_for: chrome display_name: "" @@ -1040,6 +1041,7 @@ spec: id: 0 name: bar software_package: null + packages: null source: deb_packages extension_for: "" display_name: "" @@ -1091,6 +1093,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null }, { @@ -1111,6 +1114,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null } ] diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5bc895fa443..fd0be61161b 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -212,15 +212,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } - addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) + // Return the package just added, not the title's first-added one. + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, installerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") } - if payload.AutomaticInstall { + if payload.AutomaticInstall && payload.AddedAutomaticInstallPolicy != nil { policyAct := fleet.ActivityTypeCreatedPolicy{ - ID: addedInstaller.AutomaticInstallPolicies[0].ID, - Name: addedInstaller.AutomaticInstallPolicies[0].Name, + ID: payload.AddedAutomaticInstallPolicy.ID, + Name: payload.AddedAutomaticInstallPolicy.Name, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil { @@ -411,19 +412,51 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) } - // With more than one installer on the title, this edits the first-added one. - // Choosing a specific package to edit is handled by the precedence work. if software.SoftwareInstallersCount < 1 { return nil, &fleet.BadRequestError{ Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", } } + // Defaults to the first-added package; a specific installer_id overrides it below. existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting existing installer") } + // siblings is reused for both installer targeting and the hash-collision check below. + var siblings []*fleet.SoftwareInstaller + if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 { + siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting title packages") + } + + switch { + case payload.InstallerID == 0 && software.SoftwareInstallersCount > 1: + return nil, &fleet.BadRequestError{ + Message: "installer_id is required when the title has multiple packages.", + } + case payload.InstallerID != 0: + var found bool + for _, p := range siblings { + if p.InstallerID == payload.InstallerID { + found = true + break + } + } + if !found { + return nil, ctxerr.Wrapf(ctx, ¬FoundError{}, + "installer %d does not belong to this title and team", payload.InstallerID) + } + // hydrate the targeted package the same way as the first-added default + existingInstaller, err = svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, payload.TeamID, payload.TitleID, payload.InstallerID, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting targeted installer") + } + } + } + if payload.IsNoopPayload(software) { return existingInstaller, nil // no payload, noop } @@ -500,6 +533,15 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + // Catch a sibling hash match for a friendly 409; the dedup_token key would otherwise raise a raw 1062. + for _, p := range siblings { + if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID { + return nil, ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payloadForNewInstallerFile.Filename), + }, "edit collides with sibling package hash") + } + } + activity.SoftwarePackage = &payload.Filename payload.StorageID = payloadForNewInstallerFile.StorageID payload.Filename = payloadForNewInstallerFile.Filename @@ -726,6 +768,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "processing side effects for version pin") } } + + // the pinned version is now the active installer; return it, not the one we pinned away from + payload.InstallerID = activeInstallerID default: if payloadForNewInstallerFile != nil { if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil { @@ -825,8 +870,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } - // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later - updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, true) + // re-pull the edited installer to reflect side effects; return that specific + // package, not the title's first-added one. May be able to optimize this out later. + updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") } @@ -917,7 +963,7 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi return false, nil, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("fleet_id", "is required") } @@ -928,7 +974,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return err } - // first, look for a software installer + // metaInstaller is fully hydrated (incl. the title-level icon) which the per-package reads below lack. metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -942,9 +988,40 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } + // An installer id always refers to a software installer, never a VPP or in-house app. + if installerID != nil { + if metaInstaller == nil { + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages") + } + for _, pkg := range pkgs { + if pkg.InstallerID == *installerID { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + return svc.deleteSoftwareInstaller(ctx, pkg) + } + } + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + switch { case metaInstaller != nil: - return svc.deleteSoftwareInstaller(ctx, metaInstaller) + // Delete every package on the title. FMA titles keep one active row, so this + // matches prior behavior for them. Per-package deletes mean a guarded package + // (setup experience / patch policy) fails the title delete partway. + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages to delete") + } + for _, pkg := range pkgs { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + if err := svc.deleteSoftwareInstaller(ctx, pkg); err != nil { + return err + } + } + return nil case metaVPP != nil: return svc.deleteVPPApp(ctx, teamID, metaVPP) case metaInHouse != nil: diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 76cccd05935..ac6f49782f2 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -7165,3 +7165,41 @@ WHERE return ret, nil } + +// GetCategoriesForSoftwareInstallers returns categories keyed by installer id, +// unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. +func (ds *Datastore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + if len(installerIDs) == 0 { + return map[uint][]string{}, nil + } + + stmt := ` +SELECT + sisc.software_installer_id AS installer_id, + sc.name AS software_category_name +FROM + software_installer_software_categories sisc + JOIN software_categories sc ON sc.id = sisc.software_category_id +WHERE + sisc.software_installer_id IN (?) +ORDER BY sc.name` + + stmt, args, err := sqlx.In(stmt, installerIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "sqlx.In for get categories for software installers by id") + } + var categories []struct { + InstallerID uint `db:"installer_id"` + CategoryName string `db:"software_category_name"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &categories, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get categories for software installers by id") + } + + ret := make(map[uint][]string, len(categories)) + for _, c := range categories { + ret[c.InstallerID] = append(ret[c.InstallerID], c.CategoryName) + } + + return ret, nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index c65157795a8..fd0a836a60d 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1257,6 +1257,17 @@ WHERE } func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, nil, withScriptContents) +} + +// GetSoftwareInstallerMetadataByTeamTitleAndInstallerID returns the fully-hydrated +// metadata for a specific installer (rather than the first-added one), so add/edit +// responses can echo the affected package. +func (ds *Datastore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, &installerID, withScriptContents) +} + +func (ds *Datastore) getSoftwareInstallerMetadata(ctx context.Context, teamID *uint, titleID uint, installerID *uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { var scriptContentsSelect, scriptContentsFrom string if withScriptContents { scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script ` @@ -1265,6 +1276,22 @@ func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Co LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id` } + var tmID uint + if teamID != nil { + tmID = *teamID + } + + // nil installerID selects the first-added active package; otherwise that specific one. + whereClause := `si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +ORDER BY si.id ASC +LIMIT 1` + args := []any{titleID, tmID} + if installerID != nil { + whereClause = `si.id = ? AND si.title_id = ? AND si.global_or_team_id = ?` + args = []any{*installerID, titleID, tmID} + } + query := fmt.Sprintf(` SELECT si.id, @@ -1295,19 +1322,11 @@ FROM LEFT JOIN fleet_maintained_apps fma ON fma.id = si.fleet_maintained_app_id %s WHERE - si.title_id = ? AND si.global_or_team_id = ? - AND si.is_active = 1 -ORDER BY si.id ASC -LIMIT 1`, - scriptContentsSelect, scriptContentsFrom) - - var tmID uint - if teamID != nil { - tmID = *teamID - } + %s`, + scriptContentsSelect, scriptContentsFrom, whereClause) var dest fleet.SoftwareInstaller - err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "get software installer metadata") @@ -1322,13 +1341,21 @@ LIMIT 1`, return nil, err } - categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata") - } - - if categories, ok := categoryMap[titleID]; ok { - dest.Categories = categories + if installerID != nil { + // a specific package returns its own categories, not the title-merged set + categoryMap, err := ds.GetCategoriesForSoftwareInstallers(ctx, []uint{dest.InstallerID}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata") + } + dest.Categories = categoryMap[dest.InstallerID] + } else { + categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata") + } + if categories, ok := categoryMap[titleID]; ok { + dest.Categories = categories + } } displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, titleID) @@ -1358,6 +1385,7 @@ LIMIT 1`, } func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + // Join script contents so the detail shape and the edit path get the full package. const query = ` SELECT si.id, @@ -1380,10 +1408,16 @@ SELECT si.url, COALESCE(st.name, '') AS software_title, COALESCE(st.bundle_identifier, '') AS bundle_identifier, - si.patch_query + si.patch_query, + inst.contents AS install_script, + COALESCE(pinst.contents, '') AS post_install_script, + uninst.contents AS uninstall_script FROM software_installers si JOIN software_titles st ON st.id = si.title_id + LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id + LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id + LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id WHERE si.title_id = ? AND si.global_or_team_id = ? AND si.is_active = 1 @@ -1490,8 +1524,15 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error } activateAffectedHostIDs = affectedHostIDs - if _, err := tx.ExecContext(ctx, `DELETE FROM software_title_display_names WHERE (software_title_id, team_id) IN - (SELECT title_id, global_or_team_id FROM software_installers WHERE id = ?)`, id); err != nil { + // The display name is title-level (shared across sibling packages), so only remove it + // when this is the last installer on the title/team. + if _, err := tx.ExecContext(ctx, `DELETE dn FROM software_title_display_names dn + JOIN software_installers si ON si.title_id = dn.software_title_id AND si.global_or_team_id = dn.team_id + WHERE si.id = ? + AND NOT EXISTS ( + SELECT 1 FROM software_installers other + WHERE other.title_id = si.title_id AND other.global_or_team_id = si.global_or_team_id AND other.id != si.id + )`, id); err != nil { return ctxerr.Wrap(ctx, err, "delete software title display name for installer being deleted") } diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index aa295ed20a4..8758049cd56 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -483,6 +483,27 @@ func (ds *Datastore) processSoftwareTitleResults( softwareList[i].DisplayName = displayName } } + + // The main query returns one installer row per title, so fetch the full package set separately. + packagesByTitle, err := ds.GetSoftwarePackagesForTitles(ctx, opt.TeamID, titleIDs) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles") + } + // Automatic install policies are title-level for now, so attach the same set to every package. + policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies)) + for _, p := range policies { + policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p) + } + for titleID, pkgs := range packagesByTitle { + i, ok := titleIndex[titleID] + if !ok { + continue + } + for j := range pkgs { + pkgs[j].AutomaticInstallPolicies = policiesByTitle[titleID] + } + softwareList[i].Packages = pkgs + } } // Fetch matching versions separately to avoid aggregating nested arrays in the main query. @@ -557,6 +578,66 @@ func (ds *Datastore) processSoftwareTitleResults( return titles, counts, metaData, nil } +// GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' +// active packages, keyed by title id, first-added first. Backs the list packages[]. +func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { + if len(titleIDs) == 0 { + return map[uint][]fleet.SoftwarePackageListItem{}, nil + } + + const stmt = ` +SELECT + si.title_id, + si.filename AS name, + si.version, + si.platform, + si.self_service, + si.url AS package_url +FROM + software_installers si +WHERE + si.global_or_team_id = ? AND si.is_active = 1 AND si.title_id IN (?) +ORDER BY si.id ASC` + + type packageRow struct { + TitleID uint `db:"title_id"` + Name string `db:"name"` + Version string `db:"version"` + Platform string `db:"platform"` + SelfService bool `db:"self_service"` + PackageURL *string `db:"package_url"` + } + + ret := make(map[uint][]fleet.SoftwarePackageListItem) + batchSize := 32000 + err := common_mysql.BatchProcessSimple(titleIDs, batchSize, func(titleIDsToProcess []uint) error { + query, args, err := sqlx.In(stmt, ptr.ValOrZero(teamID), titleIDsToProcess) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In for get packages for titles") + } + var rows []packageRow + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil { + return ctxerr.Wrap(ctx, err, "get packages for titles") + } + for _, r := range rows { + selfService := r.SelfService + ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageListItem{ + Name: r.Name, + Version: r.Version, + Platform: r.Platform, + SelfService: &selfService, + PackageURL: r.PackageURL, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return ret, nil +} + // spliceSecondaryOrderBySoftwareTitlesSQL adds a secondary order by clause, splicing it into the // existing order by clause. This is necessary because multicolumn sort is not // supported by appendListOptionsWithCursorToSQL. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0e4a1ef72de..5608ce245c8 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -848,6 +848,10 @@ type Datastore interface { // from the title IDs to the categories assigned to the installers for those titles. GetCategoriesForSoftwareTitles(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) + // GetCategoriesForSoftwareInstallers returns categories keyed by installer ID, + // unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. + GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + // GetSoftwareTitlesForInstallAll returns the self-service software titles available // to queue for the host's "install all" action, in alphabetical order, optionally // scoped to a category. @@ -2692,10 +2696,20 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwareInstallerMetadataByTeamTitleAndInstallerID is like + // GetSoftwareInstallerMetadataByTeamAndTitleID but returns a specific installer + // (not the first-added), so add/edit responses can echo the affected package. + GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given - // title and team, ordered first-added first, each with its label scope. + // title and team, ordered first-added first, each with its label scope and + // script contents. GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) + // GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' + // active packages, keyed by title id, first-added first; backs the list packages[]. + GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]SoftwarePackageListItem, error) + // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true // the versions will be sorted by their version semver or string. diff --git a/server/fleet/service.go b/server/fleet/service.go index 607767b1ce1..429ceafa652 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1391,7 +1391,7 @@ type Service interface { UploadSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (*SoftwareInstaller, error) UpdateSoftwareInstaller(ctx context.Context, payload *UpdateSoftwareInstallerPayload) (*SoftwareInstaller, error) - DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error + DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) GetSoftwareInstallerTokenMetadata(ctx context.Context, token string, titleID uint) (*SoftwareInstallerTokenMetadata, error) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*SoftwareInstaller, error) diff --git a/server/fleet/software.go b/server/fleet/software.go index c42c9cf4993..0a4b7b07396 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -439,8 +439,10 @@ type SoftwareTitle struct { // InHouseAppsCount is 0 or 1, indicating if the software title has // an in house app (.ipa) installer InHouseAppCount int `json:"-" db:"in_house_apps_count"` - // SoftwarePackage is the software installer information for this title. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwareInstaller `json:"software_package" db:"-"` + // Packages holds every package, first-added first; nil (marshals to null) when none. + Packages []SoftwareInstaller `json:"packages" db:"-"` // AppStoreApp is the VPP app information for this title. AppStoreApp *VPPAppStoreApp `json:"app_store_app" db:"-"` // BundleIdentifier is used by Apple installers to uniquely identify @@ -524,10 +526,12 @@ type SoftwareTitleListResult struct { // was last updated for that software title CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` - // SoftwarePackage provides software installer package information, it is - // only present if a software installer is available for the software title. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` + // Packages holds the trimmed per-package info, first-added first; nil (marshals to null) when none. + Packages []SoftwarePackageListItem `json:"packages"` + // AppStoreApp provides VPP app information, it is only present if a VPP app // is available for the software title. AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index a8926be5b4c..c655d50338a 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -846,6 +846,17 @@ type SoftwarePackageOrApp struct { Categories []string `json:"categories,omitempty"` } +// SoftwarePackageListItem is the trimmed list-response package shape; it omits the +// host-only last_install/last_uninstall fields that SoftwarePackageOrApp carries. +type SoftwarePackageListItem struct { + Name string `json:"name"` + AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"` + Version string `json:"version"` + Platform string `json:"platform"` + SelfService *bool `json:"self_service,omitempty"` + PackageURL *string `json:"package_url"` +} + func (s *SoftwarePackageOrApp) GetPlatform() string { return s.Platform } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 2b09fc27650..3a91f952e17 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -614,6 +614,8 @@ type GetSoftwareCategoryNameToIDMapFunc func(ctx context.Context, teamID uint, n type GetCategoriesForSoftwareTitlesFunc func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) +type GetCategoriesForSoftwareInstallersFunc func(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + type GetSoftwareTitlesForInstallAllFunc func(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) type AssociateMDMInstallToVerificationUUIDFunc func(ctx context.Context, installUUID string, verifyCommandUUID string, hostUUID string) error @@ -1578,8 +1580,12 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc func(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) + type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) +type GetSoftwarePackagesForTitlesFunc func(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) + type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) type ListFleetMaintainedAppActiveInstallersFunc func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) @@ -3028,6 +3034,9 @@ type DataStore struct { GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFuncInvoked bool + GetCategoriesForSoftwareInstallersFunc GetCategoriesForSoftwareInstallersFunc + GetCategoriesForSoftwareInstallersFuncInvoked bool + GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFuncInvoked bool @@ -4474,9 +4483,15 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked bool + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + GetSoftwarePackagesForTitlesFunc GetSoftwarePackagesForTitlesFunc + GetSoftwarePackagesForTitlesFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -7388,6 +7403,13 @@ func (s *DataStore) GetCategoriesForSoftwareTitles(ctx context.Context, software return s.GetCategoriesForSoftwareTitlesFunc(ctx, softwareTitleIDs, team_id) } +func (s *DataStore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + s.mu.Lock() + s.GetCategoriesForSoftwareInstallersFuncInvoked = true + s.mu.Unlock() + return s.GetCategoriesForSoftwareInstallersFunc(ctx, installerIDs) +} + func (s *DataStore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) { s.mu.Lock() s.GetSoftwareTitlesForInstallAllFuncInvoked = true @@ -10762,6 +10784,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc(ctx, teamID, titleID, installerID, withScriptContents) +} + func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { s.mu.Lock() s.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked = true @@ -10769,6 +10798,13 @@ func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, tea return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) } +func (s *DataStore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { + s.mu.Lock() + s.GetSoftwarePackagesForTitlesFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesForTitlesFunc(ctx, teamID, titleIDs) +} + func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 9944ff3d3aa..79e7117f30a 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -838,7 +838,7 @@ type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.Upload type UpdateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) -type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint) error +type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error type GenerateSoftwareInstallerTokenFunc func(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) @@ -5197,11 +5197,11 @@ func (s *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.Up return s.UpdateSoftwareInstallerFunc(ctx, payload) } -func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { s.mu.Lock() s.DeleteSoftwareInstallerFuncInvoked = true s.mu.Unlock() - return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID) + return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID, installerID) } func (s *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1ef04fbc34b..dcac822d4c6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -14295,6 +14295,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", fmt.Sprint(tm.ID)) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // empty payload cleans the software items @@ -14349,6 +14350,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // create some labels A, B and C @@ -17120,6 +17122,273 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { require.Empty(t, storedURL, "cache-hit re-apply must drop the placeholder url too") } +func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + // Two script packages with the same filename resolve to the same title + // ("deploy"), and different contents give them distinct content hashes, so + // they coexist as two packages under one title. + contentA := "#!/bin/bash\necho 'A'\n" + contentB := "#!/bin/bash\necho 'B'\n" + contentC := "#!/bin/bash\necho 'C'\n" + hashOf := func(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) } + + upload := func(content string, selfService bool, expectedStatus int, expectedErr string) { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ + Filename: "deploy.sh", + TeamID: &team.ID, + SelfService: selfService, + InstallerFile: fr, + }, expectedStatus, expectedErr) + } + + // rawSoftwareMultipart adds or edits a script package and returns the raw + // response body, so we can assert exactly which package the endpoint echoes. + rawSoftwareMultipart := func(method, path, content string, extra map[string]string) []byte { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("software", "deploy.sh") + require.NoError(t, err) + _, err = io.Copy(fw, fr) + require.NoError(t, err) + require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", team.ID))) + require.NoError(t, w.WriteField("fleet_id", fmt.Sprintf("%d", team.ID))) + for k, v := range extra { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + } + r := s.DoRawWithHeaders(method, path, body.Bytes(), http.StatusOK, headers) + defer r.Body.Close() + respBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + return respBody + } + + // Add a first package (A, not self-service), then a second (B, self-service). + // The POST response must echo the package that was just added (not the title's + // first-added one). + upload(contentA, false, http.StatusOK, "") + postBody := rawSoftwareMultipart("POST", "/api/latest/fleet/software/package", contentB, map[string]string{"self_service": "true"}) + var addResp uploadSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(postBody, &addResp)) + require.NotNil(t, addResp.SoftwarePackage) + require.Equal(t, hashOf(contentB), addResp.SoftwarePackage.StorageID, "POST echoes the just-added package, not first-added") + + // Adding a second package emits the same added_software activity. + s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), ``, 0) + + // Re-uploading identical bytes is rejected (per-title hash dedupe). + upload(contentA, false, http.StatusConflict, "already added (same SHA-256 hash)") + + var titleID uint + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &titleID, + `SELECT DISTINCT title_id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, team.ID, "deploy.sh") + }) + require.NotZero(t, titleID) + + getTitle := func() *fleet.SoftwareTitle { + var resp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, + http.StatusOK, &resp, "team_id", fmt.Sprintf("%d", team.ID)) + return resp.SoftwareTitle + } + + // --- Detail endpoint: full packages[] shape, software_package == first-added --- + title := getTitle() + require.Len(t, title.Packages, 2) + require.NotNil(t, title.SoftwarePackage) + require.Equal(t, title.Packages[0].InstallerID, title.SoftwarePackage.InstallerID) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + require.Equal(t, hashOf(contentA), title.SoftwarePackage.StorageID) + // per-package fields are independent + require.False(t, title.Packages[0].SelfService) + require.True(t, title.Packages[1].SelfService) + // scripts are hydrated (for a script package the install script is the file) + require.Equal(t, contentA, title.Packages[0].InstallScript) + require.Equal(t, contentB, title.Packages[1].InstallScript) + + installerA := title.Packages[0].InstallerID + installerB := title.Packages[1].InstallerID + + // --- List endpoint: trimmed packages[] --- + var listResp listSoftwareTitlesResponse + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listResp, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + require.Len(t, listResp.SoftwareTitles, 1) + lt := listResp.SoftwareTitles[0] + require.Len(t, lt.Packages, 2) + require.NotNil(t, lt.SoftwarePackage) + require.Equal(t, "deploy.sh", lt.SoftwarePackage.Name) + require.Equal(t, "deploy.sh", lt.Packages[0].Name) + require.NotNil(t, lt.Packages[0].SelfService) + require.False(t, *lt.Packages[0].SelfService) + require.NotNil(t, lt.Packages[1].SelfService) + require.True(t, *lt.Packages[1].SelfService) + + // The list packages[] must omit the host-only last_install/last_uninstall fields. + listRes := s.Do("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + rawList, err := io.ReadAll(listRes.Body) + require.NoError(t, err) + listRes.Body.Close() + var rawListParsed struct { + SoftwareTitles []struct { + Packages []map[string]json.RawMessage `json:"packages"` + } `json:"software_titles"` + } + require.NoError(t, json.Unmarshal(rawList, &rawListParsed)) + require.Len(t, rawListParsed.SoftwareTitles, 1) + require.Len(t, rawListParsed.SoftwareTitles[0].Packages, 2) + for _, p := range rawListParsed.SoftwareTitles[0].Packages { + _, hasLastInstall := p["last_install"] + _, hasLastUninstall := p["last_uninstall"] + require.False(t, hasLastInstall, "list packages[] must omit last_install") + require.False(t, hasLastUninstall, "list packages[] must omit last_uninstall") + } + + // --- Edit without installer_id on a multi-package title -> 400 --- + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &team.ID, + SelfService: new(true), + }, http.StatusBadRequest, "installer_id is required") + + // --- Edit B's file to A's content (sibling hash collision) -> 409, both unchanged --- + collideFile, err := fleet.NewTempFileReader(strings.NewReader(contentA), func() string { return t.TempDir() }) + require.NoError(t, err) + defer collideFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: collideFile, + }, http.StatusConflict, "already added (same SHA-256 hash)") + + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + + // --- Edit B's file to a new hash -> ok; A untouched; PATCH echoes the edited package --- + patchBody := rawSoftwareMultipart("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), + contentC, map[string]string{"installer_id": fmt.Sprintf("%d", installerB)}) + var patchResp getSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(patchBody, &patchResp)) + require.NotNil(t, patchResp.SoftwareInstaller) + require.Equal(t, installerB, patchResp.SoftwareInstaller.InstallerID, "PATCH echoes the edited package, not first-added") + require.Equal(t, hashOf(contentC), patchResp.SoftwareInstaller.StorageID) + title = getTitle() + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Re-save B's current file -> no-op (200) --- + sameFile, err := fleet.NewTempFileReader(strings.NewReader(contentC), func() string { return t.TempDir() }) + require.NoError(t, err) + defer sameFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: sameFile, + }, http.StatusOK, "") + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Per-package categories: a targeted PATCH echo must match GET, not the title-merged union --- + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, InstallerID: installerA, TeamID: &team.ID, Categories: []string{"Browsers"}, + }, http.StatusOK, "") + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, InstallerID: installerB, TeamID: &team.ID, Categories: []string{"Productivity"}, + }, http.StatusOK, "") + title = getTitle() + catsA := title.Packages[0].Categories + catsB := title.Packages[1].Categories + require.NotEmpty(t, catsA) + require.NotEmpty(t, catsB) + require.NotEqual(t, catsA, catsB, "packages carry distinct categories") + + // A noop PATCH targeting the first-added installer must echo that installer's own + // categories, not the union across sibling packages. + var noopBody bytes.Buffer + noopW := multipart.NewWriter(&noopBody) + require.NoError(t, noopW.WriteField("team_id", fmt.Sprintf("%d", team.ID))) + require.NoError(t, noopW.WriteField("installer_id", fmt.Sprintf("%d", installerA))) + require.NoError(t, noopW.Close()) + noopResp := s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), + noopBody.Bytes(), http.StatusOK, map[string]string{ + "Content-Type": noopW.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + }) + noopRaw, err := io.ReadAll(noopResp.Body) + require.NoError(t, err) + noopResp.Body.Close() + var noopParsed getSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(noopRaw, &noopParsed)) + require.NotNil(t, noopParsed.SoftwareInstaller) + require.Equal(t, installerA, noopParsed.SoftwareInstaller.InstallerID) + require.ElementsMatch(t, catsA, noopParsed.SoftwareInstaller.Categories, + "noop PATCH echo must return the targeted installer's own categories, not the title-merged set") + + // A title-level display name is shared across sibling packages; deleting one must not wipe it. + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `INSERT INTO software_title_display_names (team_id, software_title_id, display_name) VALUES (?, ?, ?)`, + team.ID, titleID, "My Deploy Tool") + return err + }) + displayName := func() string { + var name string + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &name, + `SELECT COALESCE(MAX(display_name), '') FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?`, + team.ID, titleID) + }) + return name + } + + // --- Delete one package (A) -> 204, B remains and becomes first-added --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID), "installer_id", fmt.Sprintf("%d", installerA)) + title = getTitle() + require.Len(t, title.Packages, 1) + require.Equal(t, installerB, title.Packages[0].InstallerID) + require.Equal(t, installerB, title.SoftwarePackage.InstallerID) + require.Equal(t, "My Deploy Tool", displayName(), "deleting one of several packages must not wipe the shared display name") + + // --- Delete all remaining (no installer_id) -> 204, no packages left --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID)) + require.Empty(t, displayName(), "deleting the last package removes the title display name") + var remaining int + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &remaining, + `SELECT COUNT(*) FROM software_installers WHERE global_or_team_id = ? AND title_id = ?`, team.ID, titleID) + }) + require.Zero(t, remaining, "title-level delete removes all packages") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, creates a new software title @@ -33472,7 +33741,13 @@ func (s *integrationEnterpriseTestSuite) TestFleetMaintainedAppVersionPin() { "team_id": {fmt.Sprint(team.ID)}, "version": {version}, }) - s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) + resp := s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) + var patchResp getSoftwareInstallerResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&patchResp)) + resp.Body.Close() + // the response must reflect the newly-pinned active installer, not the one pinned away from + require.NotNil(t, patchResp.SoftwareInstaller) + require.Equal(t, getPkg().Version, patchResp.SoftwareInstaller.Version) } // requireLastPinActivity asserts the latest activity is an edited_software with want as pinned_version. // Marshaling want renders a pin as "1.0"/"^2" and a cleared pin (nil) as null, so no branching is needed. diff --git a/server/service/software_installers.go b/server/service/software_installers.go index ed85e4ffa79..feb5a7a3332 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -42,7 +42,9 @@ type uploadSoftwareInstallerRequest struct { } type updateSoftwareInstallerRequest struct { - TitleID uint `url:"id"` + TitleID uint `url:"id"` + // InstallerID selects which package to edit; required when the title has multiple. + InstallerID *uint File *multipart.FileHeader TeamID *uint InstallScript *string @@ -122,6 +124,14 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.TeamID = ptr.Uint(uint(fleetID)) } + if idVal, ok := r.MultipartForm.Value["installer_id"]; ok && len(idVal) > 0 && idVal[0] != "" { + installerID, err := strconv.ParseUint(idVal[0], 10, 32) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid installer_id: %s", idVal[0])} + } + decoded.InstallerID = new(uint(installerID)) + } + installScriptMultipart, ok := r.MultipartForm.Value["install_script"] if ok && len(installScriptMultipart) > 0 { decoded.InstallScript = &installScriptMultipart[0] @@ -254,6 +264,7 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s payload := &fleet.UpdateSoftwareInstallerPayload{ TitleID: req.TitleID, + InstallerID: ptr.ValOrZero(req.InstallerID), TeamID: req.TeamID, InstallScript: req.InstallScript, PreInstallQuery: req.PreInstallQuery, @@ -500,8 +511,10 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } type deleteSoftwareInstallerRequest struct { - TeamID *uint `query:"team_id" renameto:"fleet_id"` - TitleID uint `url:"title_id"` + TeamID *uint `query:"team_id" renameto:"fleet_id"` + // InstallerID deletes one package; omitted deletes all of the title's packages. + InstallerID *uint `query:"installer_id,optional"` + TitleID uint `url:"title_id"` } type deleteSoftwareInstallerResponse struct { @@ -513,14 +526,14 @@ func (r deleteSoftwareInstallerResponse) Status() int { return http.StatusNoCon func deleteSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*deleteSoftwareInstallerRequest) - err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID) + err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID, req.InstallerID) if err != nil { return deleteSoftwareInstallerResponse{Err: err}, nil } return deleteSoftwareInstallerResponse{}, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 26a87d5374b..5e6a3f887e4 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -98,6 +98,9 @@ func TestSoftwareInstallersAuth(t *testing.T) { ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{TeamID: tt.teamID}, nil } + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + return []*fleet.SoftwareInstaller{{TeamID: tt.teamID, InstallerID: 1}}, nil + } ds.DeleteSoftwareInstallerFunc = func(ctx context.Context, installerID uint) error { return nil @@ -145,7 +148,7 @@ func TestSoftwareInstallersAuth(t *testing.T) { checkAuthErr(t, tt.shouldFailRead, err) } - err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID) + err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID, nil) if tt.teamID == nil { require.Error(t, err) } else { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 4b5cbafebc6..c3f47a5153f 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -192,40 +192,72 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint if license.IsPremium() { // add software installer data if needed if software.SoftwareInstallersCount > 0 { - meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, id) if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") + return nil, ctxerr.Wrap(ctx, err, "get software packages") } - if meta != nil { - summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, meta.InstallerID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + if len(pkgs) > 0 { + // Display name, icon, and policies are title-level; fetch once from the first-added package. + titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } - meta.Status = summary - } - software.SoftwarePackage = meta - // Populate FleetMaintainedVersions if this is an FMA - if meta != nil && meta.FleetMaintainedAppID != nil { - fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + // Categories are per-package. + installerIDs := make([]uint, len(pkgs)) + for i, pkg := range pkgs { + installerIDs[i] = pkg.InstallerID + } + categoriesByInstaller, err := svc.ds.GetCategoriesForSoftwareInstallers(ctx, installerIDs) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + return nil, ctxerr.Wrap(ctx, err, "get categories for software packages") } - meta.FleetMaintainedVersions = fmaVersions - // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. - pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, ctxerr.Wrap(ctx, err, "get pinned version") + for _, pkg := range pkgs { + summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, pkg.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + } + pkg.Status = summary + pkg.Categories = categoriesByInstaller[pkg.InstallerID] + + if titleMeta != nil { + pkg.DisplayName = titleMeta.DisplayName + pkg.IconUrl = titleMeta.IconUrl + // Automatic install policies are title-level for now. + pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies + } + + // Populate FleetMaintainedVersions/pin/patch policy for FMA titles. + // An FMA title has a single active package, so this runs on it. + if pkg.FleetMaintainedAppID != nil { + fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + } + pkg.FleetMaintainedVersions = fmaVersions + + // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. + pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, err, "get pinned version") + } + pkg.PinnedVersion = pinnedVersion + + patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get patch policy") + } + pkg.PatchPolicy = patchPolicy + } } - meta.PinnedVersion = pinnedVersion - // Populate PatchPolicy if there is one - patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) - if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get patch policy") + // software_package is kept for backwards compatibility and equals the first-added package. + software.Packages = make([]fleet.SoftwareInstaller, len(pkgs)) + for i, pkg := range pkgs { + software.Packages[i] = *pkg } - meta.PatchPolicy = patchPolicy + software.SoftwarePackage = pkgs[0] } } diff --git a/server/service/testing_client_test.go b/server/service/testing_client_test.go index 5f1770b09e2..20413129013 100644 --- a/server/service/testing_client_test.go +++ b/server/service/testing_client_test.go @@ -956,6 +956,9 @@ func (ts *withServer) updateSoftwareInstaller( tmID = *payload.TeamID } require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", tmID))) + if payload.InstallerID != 0 { + require.NoError(t, w.WriteField("installer_id", fmt.Sprintf("%d", payload.InstallerID))) + } // add the remaining fields if payload.InstallScript != nil { require.NoError(t, w.WriteField("install_script", *payload.InstallScript))