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 (