From ac4ec2ff276f25721f765ff10be92f1d768a3a9f Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Tue, 24 Feb 2026 14:00:32 -0500 Subject: [PATCH] FMA version rollback (#40038) - **Gitops specify FMA rollback version (#39582)** - **Fleet UI: Show versions options for FMA installers (#39583)** - **rollback: DB and core implementation (#39650)** **Related issue:** Resolves #31919 # 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. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] 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 --------- Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Co-authored-by: Carlo DiCelico --- changes/31919-rollback | 1 + changes/31919-surface-latest-fma-version | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 2 +- ee/server/service/maintained_apps.go | 4 +- ee/server/service/software_installers.go | 63 +- .../components/FileDetails/FileDetails.tsx | 15 + .../components/FileUploader/FileUploader.tsx | 4 + frontend/components/FileUploader/_styles.scss | 14 +- .../DropdownOptionTooltipWrapper.tsx | 4 +- frontend/hooks/useSoftwareInstallerMeta.ts | 22 + frontend/interfaces/software.ts | 6 + .../FleetAppDetailsForm.tsx | 15 +- .../EditSoftwareModal/EditSoftwareModal.tsx | 1 + .../InstallerDetailsWidget.tsx | 18 + .../SoftwareInstallerCard.tsx | 2 + .../forms/PackageForm/PackageForm.tsx | 201 +++- .../components/forms/PackageForm/helpers.tsx | 15 +- .../PackageVersionSelector.tests.tsx | 195 ++++ .../PackageVersionSelector.tsx | 78 ++ .../forms/PackageVersionSelector/_styles.scss | 3 + .../forms/PackageVersionSelector/index.ts | 1 + .../SoftwareAndroidForm.tsx | 13 +- .../SoftwareOptionsSelector.tests.tsx | 86 +- .../SoftwareOptionsSelector.tsx | 64 +- .../forms/SoftwareVppForm/SoftwareVppForm.tsx | 77 +- pkg/spec/gitops_test.go | 2 + pkg/spec/testdata/team_config.yml | 1 + pkg/spec/testdata/team_config_no_paths.yml | 1 + pkg/spec/testdata/team_config_only_sha256.yml | 1 + .../20260218175704_FMAActiveInstallers.go | 36 + server/datastore/mysql/schema.sql | 8 +- server/datastore/mysql/software_installers.go | 168 +++- server/datastore/mysql/software_titles.go | 134 ++- .../select_software_titles_sql_fixture.gz | Bin 32987 -> 34263 bytes server/fleet/datastore.go | 14 + server/fleet/maintained_apps.go | 14 +- server/fleet/scripts.go | 5 +- server/fleet/software.go | 8 + server/fleet/software_installer.go | 35 +- server/mdm/maintainedapps/sync.go | 49 +- server/mock/datastore_mock.go | 36 + server/service/client.go | 1 + server/service/integration_enterprise_test.go | 948 +++++++++++++++++- server/service/software_titles.go | 9 + .../generated_files/teamconfig.txt | 2 + 45 files changed, 2129 insertions(+), 248 deletions(-) create mode 100644 changes/31919-rollback create mode 100644 changes/31919-surface-latest-fma-version create mode 100644 frontend/pages/SoftwarePage/components/forms/PackageVersionSelector/PackageVersionSelector.tests.tsx create mode 100644 frontend/pages/SoftwarePage/components/forms/PackageVersionSelector/PackageVersionSelector.tsx create mode 100644 frontend/pages/SoftwarePage/components/forms/PackageVersionSelector/_styles.scss create mode 100644 frontend/pages/SoftwarePage/components/forms/PackageVersionSelector/index.ts create mode 100644 server/datastore/mysql/migrations/tables/20260218175704_FMAActiveInstallers.go diff --git a/changes/31919-rollback b/changes/31919-rollback new file mode 100644 index 0000000000..df2322eddd --- /dev/null +++ b/changes/31919-rollback @@ -0,0 +1 @@ +- Added ability to roll back to previously added versions of Fleet-maintained apps. diff --git a/changes/31919-surface-latest-fma-version b/changes/31919-surface-latest-fma-version new file mode 100644 index 0000000000..b5ab747e25 --- /dev/null +++ b/changes/31919-surface-latest-fma-version @@ -0,0 +1 @@ +- Fleet UI: Surface FMA version used and whether it's out of date diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 58a4638a62..9b5eb89b6a 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1639,7 +1639,7 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, fma, err := maintained_apps.Hydrate(context.Background(), &fleet.MaintainedApp{ ID: *softwareTitle.SoftwarePackage.FleetMaintainedAppID, Slug: slug, - }) + }, "", nil, nil) if err != nil { return nil, err } diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 804d3b4d1f..03c2db89c6 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -64,7 +64,7 @@ func (svc *Service) AddFleetMaintainedApp( return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id") } - app, err = maintained_apps.Hydrate(ctx, app) + app, err = maintained_apps.Hydrate(ctx, app, "", teamID, nil) if err != nil { return 0, ctxerr.Wrap(ctx, err, "hydrating app from manifest") } @@ -267,5 +267,5 @@ func (svc *Service) GetFleetMaintainedApp(ctx context.Context, appID uint, teamI return nil, err } - return maintained_apps.Hydrate(ctx, app) + return maintained_apps.Hydrate(ctx, app, "", teamID, nil) } diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index df8975abd3..8b767c8efd 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -872,11 +872,34 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof return fleet.ErrNoContext } - if meta.Extension == "ipa" { + switch { + case meta.Extension == "ipa": if err := svc.ds.DeleteInHouseApp(ctx, meta.InstallerID); err != nil { return ctxerr.Wrap(ctx, err, "deleting in house app") } - } else { + case meta.FleetMaintainedAppID != nil: + // For FMA installers there may be multiple cached versions (active + up to + // N-1 inactive ones). Delete the active version first so that the + // policy-automation and setup-experience guard-rails are enforced, then + // sweep up any remaining inactive cached versions. + if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil { + return ctxerr.Wrap(ctx, err, "deleting active FMA installer version") + } + // After the active row is gone, fetch whatever cached versions remain and + // delete them. GetFleetMaintainedVersionsByTitleID queries the live DB, so + // it will not return the row we just deleted. + if meta.TitleID != nil { + cachedVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, meta.TeamID, *meta.TitleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting cached FMA versions for cleanup") + } + for _, v := range cachedVersions { + if err := svc.ds.DeleteSoftwareInstaller(ctx, v.ID); err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "deleting cached FMA version") + } + } + } + default: if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil { return ctxerr.Wrap(ctx, err, "deleting software installer") } @@ -2053,7 +2076,7 @@ func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payloa } return err } - _, err = maintained_apps.Hydrate(ctx, app) + _, err = maintained_apps.Hydrate(ctx, app, payload.RollbackVersion, teamID, svc.ds) if err != nil { return err } @@ -2246,6 +2269,7 @@ func (svc *Service) softwareBatchUpload( ValidatedLabels: p.ValidatedLabels, Categories: p.Categories, DisplayName: p.DisplayName, + RollbackVersion: p.RollbackVersion, } var extraInstallers []*fleet.UploadSoftwareInstallerPayload @@ -2345,16 +2369,27 @@ func (svc *Service) softwareBatchUpload( } } + // For FMA installers, check if this version is already cached for this team. + var fmaVersionCached bool + if p.Slug != nil && *p.Slug != "" && p.MaintainedApp != nil && p.MaintainedApp.Version != "" { + cached, err := svc.ds.HasFMAInstallerVersion(ctx, teamID, p.MaintainedApp.ID, p.MaintainedApp.Version) + if err != nil { + return ctxerr.Wrap(ctx, err, "check cached FMA version") + } + fmaVersionCached = cached + installer.FMAVersionCached = cached + } + var installerBytesExist bool - if p.SHA256 != "" { + if !fmaVersionCached && p.SHA256 != "" { installerBytesExist, err = svc.softwareInstallStore.Exists(ctx, installer.StorageID) if err != nil { - return err + return ctxerr.Wrap(ctx, err, "check if installer exists in store") } } // no accessible matching installer was found, so attempt to download it from URL. - if installer.StorageID == "" || !installerBytesExist { + if !fmaVersionCached && (installer.StorageID == "" || !installerBytesExist) { if p.SHA256 != "" && p.URL == "" { return fmt.Errorf("package not found with hash %s", p.SHA256) } @@ -2405,8 +2440,9 @@ func (svc *Service) softwareBatchUpload( } // noCheckHash is used by homebrew to signal that a hash shouldn't be checked // This comes from the manifest and is a special case for maintained apps - // we need to generate the SHA256 from the installer file - if p.MaintainedApp.SHA256 == noCheckHash { + // we need to generate the SHA256 from the installer file. + // Skip when version is cached — the existing row already has the computed hash. + if !fmaVersionCached && p.MaintainedApp.SHA256 == noCheckHash { // generate the SHA256 from the installer file if installer.InstallerFile == nil { return fmt.Errorf("maintained app %s requires hash to be generated but no installer file found", p.MaintainedApp.UniqueIdentifier) @@ -2422,7 +2458,8 @@ func (svc *Service) softwareBatchUpload( // Some FMAs (e.g. Chrome for macOS) aren't version-pinned by URL, so we have to extract the // version from the package once we download it. - if installer.Version == "latest" && installer.InstallerFile != nil { + // Skip when version is cached — the existing row already has the correct version. + if !fmaVersionCached && installer.Version == "latest" && installer.InstallerFile != nil { meta, err := file.ExtractInstallerMetadata(installer.InstallerFile) if err != nil { return ctxerr.Wrap(ctx, err, "extracting installer metadata") @@ -2558,9 +2595,11 @@ func (svc *Service) softwareBatchUpload( var inHouseInstallers, softwareInstallers []*fleet.UploadSoftwareInstallerPayload for _, payloadWithExtras := range installers { payload := payloadWithExtras.UploadSoftwareInstallerPayload - if err := svc.storeSoftware(ctx, payload); err != nil { - batchErr = fmt.Errorf("storing software installer %q: %w", payload.Filename, err) - return + if !payload.FMAVersionCached { + if err := svc.storeSoftware(ctx, payload); err != nil { + batchErr = fmt.Errorf("storing software installer %q: %w", payload.Filename, err) + return + } } if payload.Extension == "ipa" { inHouseInstallers = append(inHouseInstallers, payload) diff --git a/frontend/components/FileDetails/FileDetails.tsx b/frontend/components/FileDetails/FileDetails.tsx index 9d3ec5b026..26f4f84007 100644 --- a/frontend/components/FileDetails/FileDetails.tsx +++ b/frontend/components/FileDetails/FileDetails.tsx @@ -20,6 +20,8 @@ interface IFileDetailsProps { | IFileDetailsSupportedGraphicNames[]; fileDetails: IFileDetails; canEdit: boolean; + /** If present, will default to a custom editor section instead of edit icon */ + customEditor?: () => React.ReactNode; /** If present, will show a trash icon */ onDeleteFile?: () => void; onFileSelect?: (e: React.ChangeEvent) => void; @@ -36,6 +38,7 @@ const FileDetails = ({ graphicNames, fileDetails, canEdit, + customEditor, onDeleteFile, onFileSelect, accept, @@ -56,6 +59,18 @@ const FileDetails = ({ }); const renderEditButton = (disabled?: boolean) => { + if (customEditor) { + return ( +
{ + e.stopPropagation(); + }} + > + {customEditor()} +
+ ); + } + return (