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)**

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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 <carlo@fleetdm.com>
This commit is contained in:
Jahziel Villasana-Espinoza 2026-02-24 14:00:32 -05:00 committed by GitHub
parent a58678ea28
commit ac4ec2ff27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2129 additions and 248 deletions

1
changes/31919-rollback Normal file
View file

@ -0,0 +1 @@
- Added ability to roll back to previously added versions of Fleet-maintained apps.

View file

@ -0,0 +1 @@
- Fleet UI: Surface FMA version used and whether it's out of date

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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<HTMLInputElement>) => 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 (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
{customEditor()}
</div>
);
}
return (
<div className={`${baseClass}__edit`}>
<Button

View file

@ -60,6 +60,8 @@ interface IFileUploaderProps {
onFileUpload: (files: FileList | null) => void;
/** renders the current file with the edit pencil button */
canEdit?: boolean;
/** renders a custom editor for the current file replacing the edit pencil button */
customEditor?: () => React.ReactNode;
/** renders the current file with the delete trash button */
onDeleteFile?: () => void;
/** if provided, will be called when the button is clicked
@ -98,6 +100,7 @@ export const FileUploader = ({
onButtonClick,
onFileUpload,
canEdit = false,
customEditor,
onDeleteFile,
fileDetails,
gitopsCompatible = false,
@ -278,6 +281,7 @@ export const FileUploader = ({
graphicNames={graphicNames}
fileDetails={fileDetails}
canEdit={canEdit}
customEditor={customEditor}
onDeleteFile={onDeleteFile}
onFileSelect={onFileSelect}
accept={accept}

View file

@ -7,7 +7,15 @@
border: 1px solid $ui-fleet-black-10;
padding: $pad-xlarge $pad-large;
font-size: $x-small;
text-align: center;
&:not(.file-uploader__file-preview) {
text-align: center;
// Preview may include dropdown input that needs to be shown
input {
display: none;
}
}
.content-wrapper {
@include content-wrapper();
@ -42,10 +50,6 @@
color: $ui-fleet-black-50;
}
input {
display: none;
}
&__upload-button {
// we handle the padding in the label so the entire button is clickable
padding: 0;

View file

@ -29,9 +29,7 @@ const DropdownOptionTooltipWrapper = ({
offset = 24,
}: IDropdownOptionTooltipWrapper) => {
const wrapperClassNames = classnames(baseClass, className);
const elementClassNames = classnames(`${baseClass}__element`);
const tipClassNames = classnames(
`${baseClass}__tip-text`,
`${baseClass}__dropdown-tooltip-arrow`,
@ -55,7 +53,7 @@ const DropdownOptionTooltipWrapper = ({
disableStyleInjection
clickable={clickable}
offset={offset}
positionStrategy="fixed"
positionStrategy="fixed" // TODO: Found out this does not work with a dropdown within a Modal, it renders the tooltip in the wrong place
>
{tipContent}
</ReactTooltip5>

View file

@ -13,11 +13,13 @@ import {
getInstallerCardInfo,
InstallerCardInfo,
} from "pages/SoftwarePage/SoftwareTitleDetailsPage/helpers";
import { compareVersions } from "utilities/helpers";
export interface SoftwareInstallerMeta {
installerType: InstallerType;
isAndroidPlayStoreApp: boolean;
isFleetMaintainedApp: boolean;
isLatestFmaVersion: boolean;
isCustomPackage: boolean;
isIosOrIpadosApp: boolean;
sha256?: string;
@ -66,6 +68,24 @@ export const useSoftwareInstaller = (
"fleet_maintained_app_id" in softwareInstaller &&
!!softwareInstaller.fleet_maintained_app_id;
const isLatestFmaVersion =
isFleetMaintainedApp &&
"fleet_maintained_versions" in softwareInstaller &&
!!softwareInstaller.fleet_maintained_versions &&
softwareInstaller.fleet_maintained_versions.every(
(fma) =>
// Verify that the installer version is not older than any known
// Fleetmaintained version by requiring compareVersions to return
// 0 (equal) or 1 (greater) for every entry.
compareVersions(softwareInstaller.version ?? "", fma.version ?? "") >=
0
);
const fmaVersions =
isFleetMaintainedApp && "fleet_maintained_versions" in softwareInstaller
? softwareInstaller.fleet_maintained_versions
: [];
const isCustomPackage =
installerType === "package" && !isFleetMaintainedApp;
@ -110,6 +130,8 @@ export const useSoftwareInstaller = (
installerType,
isAndroidPlayStoreApp,
isFleetMaintainedApp,
isLatestFmaVersion,
fmaVersions,
isCustomPackage,
isIosOrIpadosApp,
sha256,

View file

@ -94,6 +94,11 @@ export interface ISoftwareAppStoreAppStatus {
failed: number;
}
interface IFleetMaintainedVersion {
id: number;
version: string;
}
export interface ISoftwarePackage {
name: string;
/** Not included in SoftwareTitle software.software_package response, hoisted up one level
@ -118,6 +123,7 @@ export interface ISoftwarePackage {
labels_exclude_any: ILabelSoftwareTitle[] | null;
categories?: SoftwareCategory[] | null;
fleet_maintained_app_id?: number | null;
fleet_maintained_versions?: IFleetMaintainedVersion[] | null;
hash_sha256?: string | null;
}

View file

@ -145,14 +145,17 @@ const FleetAppDetailsForm = ({
setFormValidation(generateFormValidation(newData));
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstallCheckbox = (value: boolean) => {
const newData = { ...formData, automaticInstall: value };
const onToggleAutomaticInstall = () => {
const newData = {
...formData,
automaticInstall: !formData.automaticInstall,
};
setFormData(newData);
};
@ -226,8 +229,8 @@ const FleetAppDetailsForm = ({
<Card paddingSize="medium" borderRadiusSize="large">
<SoftwareOptionsSelector
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstallCheckbox}
onToggleSelfService={onToggleSelfServiceCheckbox}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
disableOptions={isSoftwareAlreadyAdded}
onClickPreviewEndUserExperience={onClickPreviewEndUserExperience}

View file

@ -353,6 +353,7 @@ const EditSoftwareModal = ({
labels={labels || []}
className={formClassNames}
isEditingSoftware
isFleetMaintainedApp={isFleetMaintainedApp}
onCancel={onExit}
onSubmit={onClickSavePackage}
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}

View file

@ -66,6 +66,7 @@ interface IInstallerDetailsWidgetProps {
version?: string | null;
sha256?: string | null;
isFma: boolean;
isLatestFmaVersion?: boolean;
isScriptPackage: boolean;
androidPlayStoreId?: string;
customDetails?: string;
@ -79,6 +80,7 @@ const InstallerDetailsWidget = ({
sha256,
version,
isFma,
isLatestFmaVersion = false,
isScriptPackage,
androidPlayStoreId,
customDetails,
@ -122,6 +124,22 @@ const InstallerDetailsWidget = ({
let versionInfo = <span>{version}</span>;
if (isFma) {
versionInfo = (
<TooltipWrapper
tipContent={
<span>
You can change the version in <strong>Actions &gt; Edit</strong>{" "}
software.
</span>
}
>
<span>
{version} {isLatestFmaVersion ? "(latest)" : ""}
</span>
</TooltipWrapper>
);
}
if (installerType === "app-store") {
versionInfo = (
<TooltipWrapper tipContent={<span>Updated every hour.</span>}>

View file

@ -208,6 +208,7 @@ const SoftwareInstallerCard = ({
installerType,
isAndroidPlayStoreApp,
isFleetMaintainedApp,
isLatestFmaVersion,
isCustomPackage,
isIosOrIpadosApp,
sha256,
@ -280,6 +281,7 @@ const SoftwareInstallerCard = ({
addedTimestamp={addedTimestamp}
sha256={sha256}
isFma={isFleetMaintainedApp}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
androidPlayStoreId={androidPlayStoreId}
/>

View file

@ -12,7 +12,9 @@ import getDefaultInstallScript from "utilities/software_install_scripts";
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
import { ILabelSummary } from "interfaces/label";
import { SoftwareCategory } from "interfaces/software";
import { ISoftwareVersion, SoftwareCategory } from "interfaces/software";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
@ -29,12 +31,18 @@ import Card from "components/Card";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import PackageAdvancedOptions from "../PackageAdvancedOptions";
import { createTooltipContent, generateFormValidation } from "./helpers";
import {
createTooltipContent,
generateFormValidation,
sortByVersionLatestFirst,
} from "./helpers";
import PackageVersionSelector from "../PackageVersionSelector";
export const baseClass = "package-form";
export interface IPackageFormData {
software: File | null;
version?: string;
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
@ -80,6 +88,31 @@ const renderFileTypeMessage = () => {
);
};
/** Returns the version value to use as the dropdown's default:
/ 1) If a previously selected version is still present in the options, reuse it.
/ 2) Otherwise, fall back to the first option, which is assumed to be the latest.
/ 3) Safe fallback if no options exist which should never happen */
const getDefaultVersion = (
versionOptions: CustomOptionType[],
selectedVersion?: string
) => {
// This shouldn't happen
if (!versionOptions.length) {
return "";
}
// If we already have a selected version and it exists in options, keep it
if (selectedVersion) {
const match = versionOptions.find((opt) => opt.value === selectedVersion);
if (match) {
return match.value;
}
}
// Otherwise, default to the first option (which should be latest)
return versionOptions[0].value;
};
interface IPackageFormProps {
labels: ILabelSummary[];
showSchemaButton?: boolean;
@ -88,6 +121,7 @@ interface IPackageFormProps {
onClickShowSchema?: () => void;
onClickPreviewEndUserExperience: (isIosOrIpadosApp: boolean) => void;
isEditingSoftware?: boolean;
isFleetMaintainedApp?: boolean;
defaultSoftware?: any; // TODO
defaultInstallScript?: string;
defaultPreInstallQuery?: string;
@ -111,6 +145,7 @@ const PackageForm = ({
onSubmit,
onClickPreviewEndUserExperience,
isEditingSoftware = false,
isFleetMaintainedApp = false,
defaultSoftware,
defaultInstallScript,
defaultPreInstallQuery,
@ -127,6 +162,7 @@ const PackageForm = ({
const initialFormData: IPackageFormData = {
software: defaultSoftware || null,
version: defaultSoftware?.version || "",
installScript: defaultInstallScript || "",
preInstallQuery: defaultPreInstallQuery || "",
postInstallScript: defaultPostInstallScript || "",
@ -213,16 +249,18 @@ const PackageForm = ({
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstallCheckbox = useCallback(
(value: boolean) => {
const newData = { ...formData, automaticInstall: value };
setFormData(newData);
const onToggleAutomaticInstall = useCallback(
(value?: boolean) => {
const automaticInstall =
typeof value === "boolean" ? value : !formData.automaticInstall;
setFormData({ ...formData, automaticInstall });
},
[formData]
);
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
@ -276,6 +314,17 @@ const PackageForm = ({
setFormValidation(generateFormValidation(newData));
};
const onSelectVersion = (version: string) => {
// For now we can only update version in GitOps
// Selection is currently disabled in the UI
const newData = {
...formData,
version,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const disableFieldsForGitOps = gitopsCompatible && gitOpsModeEnabled;
const isSubmitDisabled = !formValidation.isValid || disableFieldsForGitOps;
const submitTooltipContent = createTooltipContent(
@ -302,7 +351,7 @@ const PackageForm = ({
(isExePackage || isTarballPackage || isScriptPackage || isIpaPackage) &&
formData.automaticInstall
) {
onToggleAutomaticInstallCheckbox(false);
onToggleAutomaticInstall(false);
}
}, [
formData.automaticInstall,
@ -310,7 +359,7 @@ const PackageForm = ({
isTarballPackage,
isScriptPackage,
isIpaPackage,
onToggleAutomaticInstallCheckbox,
onToggleAutomaticInstall,
]);
// Show advanced options when a package is selected that's not a script or ipa
@ -320,11 +369,80 @@ const PackageForm = ({
// GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector
const showOptionsTargetsSelectors = !gitOpsModeEnabled;
const renderSoftwareOptionsSelector = () => (
<SoftwareOptionsSelector
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
isCustomPackage
isEditingSoftware={isEditingSoftware}
isExePackage={isExePackage}
isTarballPackage={isTarballPackage}
isScriptPackage={isScriptPackage}
isIpaPackage={isIpaPackage}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isIpaPackage)
}
/>
);
const renderTargetLabelSelector = () => (
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
formData.targetType === "Custom" &&
generateHelpText(formData.automaticInstall, formData.customTarget)
}
/>
);
const renderCustomEditor = () => {
if (isEditingSoftware && !isFleetMaintainedApp) {
return null;
}
const fmaVersionsSortedByLatestFirst = sortByVersionLatestFirst<ISoftwareVersion>(
defaultSoftware.fleet_maintained_versions || []
);
const hasMultipleVersions = fmaVersionsSortedByLatestFirst.length > 1;
const versionOptions = fmaVersionsSortedByLatestFirst.map(
(v: ISoftwareVersion, index: number) => {
// If multiple versions, only adds "Latest" label to the first option
const labelLatestVersion = hasMultipleVersions && index === 0;
return {
label: labelLatestVersion ? `Latest (${v.version})` : `${v.version}`,
value: v.version,
};
}
);
return (
<PackageVersionSelector
selectedVersion={getDefaultVersion(versionOptions, formData.version)}
versionOptions={versionOptions}
onSelectVersion={onSelectVersion}
className={`${baseClass}__version-selector`}
/>
);
};
return (
<div className={classNames}>
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
<FileUploader
canEdit={canEditFile}
customEditor={renderCustomEditor}
graphicName={getGraphicName(ext || "")}
accept={ACCEPTED_EXTENSIONS}
message={renderFileTypeMessage()}
@ -349,49 +467,26 @@ const PackageForm = ({
>
{showOptionsTargetsSelectors && (
<div className={`${baseClass}__form-frame`}>
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
<SoftwareOptionsSelector
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstallCheckbox}
onToggleSelfService={onToggleSelfServiceCheckbox}
onSelectCategory={onSelectCategory}
isCustomPackage
isEditingSoftware={isEditingSoftware}
isExePackage={isExePackage}
isTarballPackage={isTarballPackage}
isScriptPackage={isScriptPackage}
isIpaPackage={isIpaPackage}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isIpaPackage)
}
/>
</Card>
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
formData.targetType === "Custom" &&
generateHelpText(
formData.automaticInstall,
formData.customTarget
)
}
/>
</Card>
{isEditingSoftware ? (
renderSoftwareOptionsSelector()
) : (
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
{renderSoftwareOptionsSelector()}
</Card>
)}
{isEditingSoftware ? (
renderTargetLabelSelector()
) : (
<Card
paddingSize="medium"
borderRadiusSize={isEditingSoftware ? "medium" : "large"}
>
{renderTargetLabelSelector()}
</Card>
)}
</div>
)}
</div>

View file

@ -1,9 +1,8 @@
import React from "react";
import { validateQuery } from "components/forms/validators/validate_query";
import { getExtensionFromFileName } from "utilities/file/fileUtils";
import { getGitOpsModeTipContent } from "utilities/helpers";
import { compareVersions, getGitOpsModeTipContent } from "utilities/helpers";
import { IPackageFormData, IPackageFormValidation } from "./PackageForm";
type IMessageFunc = (formData: IPackageFormData) => string;
@ -237,4 +236,16 @@ export const createTooltipContent = (
);
};
/** Keeps the latest (highest) version first and descends using compareVersions.
Works with any array of objects that expose a string `version` field. */
export const sortByVersionLatestFirst = <T extends { version?: string }>(
items: T[]
): T[] => {
return items.sort((a, b) => {
const v1 = a.version ?? "";
const v2 = b.version ?? "";
return compareVersions(v2, v1);
});
};
export default generateFormValidation;

View file

@ -0,0 +1,195 @@
import React from "react";
import { noop } from "lodash";
import { render, screen, waitFor } from "@testing-library/react";
import { renderWithSetup } from "test/test-utils";
import PackageVersionSelector from "./PackageVersionSelector";
describe("PackageVersionSelector component", () => {
it("returns null when there are no version options", () => {
const { container } = render(
<PackageVersionSelector
selectedVersion="2.0.0"
versionOptions={[]}
onSelectVersion={noop}
/>
);
expect(container.firstChild).toBeNull();
});
it("renders a plain version label when there is only one option", () => {
render(
<PackageVersionSelector
selectedVersion="2.0.0"
versionOptions={[{ value: "2.0.0", label: "2.0.0" }]}
onSelectVersion={noop}
/>
);
// Shows just the raw version
expect(screen.getByText("2.0.0")).toBeInTheDocument();
// Does not show the \"Latest (...)\" decoration when there is only one option
expect(
screen.queryByText("Latest (2.0.0)", { exact: false })
).not.toBeInTheDocument();
});
it("renders the package version dropdown when there are package versions to choose from", () => {
render(
<PackageVersionSelector
selectedVersion="2.0.0"
versionOptions={[
{ value: "2.0.0", label: "Latest (2.0.0)" },
{ value: "1.0.0", label: "1.0.0" },
]}
onSelectVersion={noop}
/>
);
// Renders the label for the selected (latest) version
expect(screen.getByText("Latest (2.0.0)")).toBeInTheDocument();
});
it("disables all non-selected options when the latest version is selected", async () => {
const { user } = renderWithSetup(
<PackageVersionSelector
selectedVersion="2.0.0"
versionOptions={[
{ value: "2.0.0", label: "Latest (2.0.0)" }, // selected
{ value: "1.0.0", label: "1.0.0" },
]}
onSelectVersion={noop}
/>
);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
const optionInnerDivs = screen.getAllByTestId("dropdown-option");
const latestInner = optionInnerDivs.find(
(el) => el.textContent === "Latest (2.0.0)"
);
const oldInner = optionInnerDivs.find((el) => el.textContent === "1.0.0");
expect(latestInner).toBeDefined();
expect(oldInner).toBeDefined();
const latestOptionWrapper = latestInner?.closest(
".react-select__option"
) as HTMLElement | null;
const oldOptionWrapper = oldInner?.closest(
".react-select__option"
) as HTMLElement | null;
expect(latestOptionWrapper).not.toBeNull();
expect(oldOptionWrapper).not.toBeNull();
// Selected option (Latest 2.0.0) is enabled
expect(latestOptionWrapper).toHaveAttribute("aria-disabled", "false");
// Non-selected option (1.0.0) is disabled
expect(oldOptionWrapper).toHaveAttribute("aria-disabled", "true");
});
it("disables all non-selected options when a non-latest version is selected", async () => {
const { user } = renderWithSetup(
<PackageVersionSelector
selectedVersion="1.0.0"
versionOptions={[
{ value: "2.0.0", label: "Latest (2.0.0)" },
{ value: "1.0.0", label: "1.0.0" }, // selected
]}
onSelectVersion={noop}
/>
);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
const optionInnerDivs = screen.getAllByTestId("dropdown-option");
const latestInner = optionInnerDivs.find(
(el) => el.textContent === "Latest (2.0.0)"
);
const oldInner = optionInnerDivs.find((el) => el.textContent === "1.0.0");
expect(latestInner).toBeDefined();
expect(oldInner).toBeDefined();
const latestOptionWrapper = latestInner?.closest(
".react-select__option"
) as HTMLElement | null;
const oldOptionWrapper = oldInner?.closest(
".react-select__option"
) as HTMLElement | null;
expect(latestOptionWrapper).not.toBeNull();
expect(oldOptionWrapper).not.toBeNull();
// Selected option (1.0.0) is enabled
expect(oldOptionWrapper).toHaveAttribute("aria-disabled", "false");
// Non-selected option (Latest 2.0.0) is disabled
expect(latestOptionWrapper).toHaveAttribute("aria-disabled", "true");
});
it("shows the GitOps rollback tooltip text when the selected version is the first (latest) option", async () => {
const { user } = renderWithSetup(
<PackageVersionSelector
selectedVersion="2.0.0"
versionOptions={[
{ value: "2.0.0", label: "Latest (2.0.0)" }, // first / latest
{ value: "1.0.0", label: "1.0.0" },
]}
onSelectVersion={noop}
/>
);
// TooltipWrapper attaches tooltip to this element:
const tooltipAnchor = document.querySelector(
".component__tooltip-wrapper__element"
) as HTMLElement;
await user.hover(tooltipAnchor);
await waitFor(() => {
expect(
screen.getByText("Currently, you can only use GitOps", { exact: false })
).toBeInTheDocument();
expect(
screen.getByText("to roll back (UI coming soon).", { exact: false })
).toBeInTheDocument();
});
});
it("shows the update-to-latest tooltip text when the selected version is not the first (latest) option", async () => {
const { user } = renderWithSetup(
<PackageVersionSelector
selectedVersion="1.0.0"
versionOptions={[
{ value: "2.0.0", label: "Latest (2.0.0)" }, // first / latest
{ value: "1.0.0", label: "1.0.0" },
]}
onSelectVersion={noop}
/>
);
const tooltipAnchor = document.querySelector(
".component__tooltip-wrapper__element"
) as HTMLElement;
await user.hover(tooltipAnchor);
await waitFor(() => {
expect(
screen.getByText("Currently, to update to latest you have", {
exact: false,
})
).toBeInTheDocument();
expect(
screen.getByText("to delete and re-add the software.", { exact: false })
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,78 @@
import React from "react";
import classnames from "classnames";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "package-version-selector";
// This is a temporary solution to disable selecting versions in the UI
// as we currently only support choosing the latest version via gitops.
const disableAllUIOptions = (
versions: CustomOptionType[],
selectedVersion: string
): CustomOptionType[] => {
return versions.map((v: CustomOptionType) => {
return {
...v,
isDisabled: v.value !== selectedVersion,
};
});
};
interface IPackageVersionSelectorProps {
className?: string;
versionOptions: CustomOptionType[];
selectedVersion: string;
onSelectVersion: (version: string) => void;
}
const PackageVersionSelector = ({
className,
versionOptions,
selectedVersion,
onSelectVersion,
}: IPackageVersionSelectorProps) => {
if (versionOptions.length === 0) {
return null;
}
const renderDropdown = () => (
<DropdownWrapper
name="package-version-selector"
className={classnames(baseClass, className)}
value={selectedVersion as string}
onChange={(version) => onSelectVersion(version?.value || "")}
options={disableAllUIOptions(versionOptions, selectedVersion)} // Replace with "versions" when we want to enable selecting versions in the UI
placeholder="Select a version"
/>
);
return (
<TooltipWrapper
tipContent={
selectedVersion === versionOptions[0].value ? (
<>
Currently, you can only use GitOps <br />
to roll back (UI coming soon).
</>
) : (
<>
Currently, to update to latest you have
<br /> to delete and re-add the software.
</>
)
}
position="top"
showArrow
underline={false}
tipOffset={8}
>
{renderDropdown()}
</TooltipWrapper>
);
};
export default PackageVersionSelector;

View file

@ -0,0 +1,3 @@
.package-version-selector {
width: 250px;
}

View file

@ -0,0 +1 @@
export { default } from "./PackageVersionSelector";

View file

@ -97,8 +97,8 @@ const SoftwareAndroidForm = ({
setFormValidation(generateFormValidation(newFormData));
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
};
@ -130,8 +130,11 @@ const SoftwareAndroidForm = ({
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstall = (value: boolean) => {
const newData = { ...formData, automaticInstall: value };
const onToggleAutomaticInstall = () => {
const newData = {
...formData,
automaticInstall: !formData.automaticInstall,
};
setFormData(newData);
};
@ -174,7 +177,7 @@ const SoftwareAndroidForm = ({
platform="android"
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfServiceCheckbox}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
onClickPreviewEndUserExperience={onClickPreviewEndUserExperience}
disableOptions

View file

@ -20,6 +20,15 @@ const defaultProps = {
onClickPreviewEndUserExperience: jest.fn(),
};
const getSwitchByLabelText = (text: string) => {
const label = screen.getByText(text);
const wrapper = label.closest(".fleet-slider__wrapper");
if (!wrapper) throw new Error(`Wrapper not found for "${text}"`);
const btn = wrapper.querySelector('button[role="switch"]');
if (!btn) throw new Error(`Switch button not found for "${text}"`);
return btn as HTMLButtonElement;
};
describe("SoftwareOptionsSelector", () => {
const renderComponent = (props = {}) => {
return createCustomRenderer({ context: {} })(
@ -27,82 +36,57 @@ describe("SoftwareOptionsSelector", () => {
);
};
it("calls onToggleSelfService when the self-service checkbox is toggled", () => {
it("calls onToggleSelfService when the self-service slider is toggled", () => {
const onToggleSelfService = jest.fn();
renderComponent({ onToggleSelfService });
const selfServiceCheckbox = screen
.getByText("Self-service")
.closest('div[role="checkbox"]');
if (selfServiceCheckbox) {
fireEvent.click(selfServiceCheckbox);
} else {
throw new Error("Self-service checkbox not found");
}
const selfServiceSwitch = getSwitchByLabelText("Self-service");
fireEvent.click(selfServiceSwitch);
expect(onToggleSelfService).toHaveBeenCalledTimes(1);
expect(onToggleSelfService).toHaveBeenCalledWith(true);
// Slider calls onChange with no args
expect(onToggleSelfService).toHaveBeenCalledWith();
});
it("calls onToggleAutomaticInstall when the automatic install checkbox is toggled", () => {
it("calls onToggleAutomaticInstall when the automatic install slider is toggled", () => {
const onToggleAutomaticInstall = jest.fn();
renderComponent({ onToggleAutomaticInstall });
const automaticInstallCheckbox = screen
.getByText("Automatic install")
.closest('div[role="checkbox"]');
if (automaticInstallCheckbox) {
fireEvent.click(automaticInstallCheckbox);
} else {
throw new Error("Automatic install checkbox not found");
}
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
fireEvent.click(automaticInstallSwitch);
expect(onToggleAutomaticInstall).toHaveBeenCalledTimes(1);
expect(onToggleAutomaticInstall).toHaveBeenCalledWith(true);
expect(onToggleAutomaticInstall).toHaveBeenCalledWith();
});
it("enables self-service and disables automatic install checkboxes for iOS", () => {
it("enables self-service and disables automatic install sliders for iOS", () => {
renderComponent({ platform: "ios" });
// Targeting the checkbox elements directly
const selfServiceCheckbox = screen
.getByText("Self-service")
.closest('[role="checkbox"]');
const automaticInstallCheckbox = screen
.getByText("Automatic install")
.closest('[role="checkbox"]');
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceCheckbox).toHaveAttribute("aria-disabled", "false");
expect(automaticInstallCheckbox).toHaveAttribute("aria-disabled", "true");
expect(selfServiceSwitch.disabled).toBe(false);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("enables self-service and disables automatic install checkboxes for iPadOS", () => {
it("enables self-service and disables automatic install sliders for iPadOS", () => {
renderComponent({ platform: "ipados" });
// Targeting the checkbox elements directly
const selfServiceCheckbox = screen
.getByText("Self-service")
.closest('[role="checkbox"]');
const automaticInstallCheckbox = screen
.getByText("Automatic install")
.closest('[role="checkbox"]');
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceCheckbox).toHaveAttribute("aria-disabled", "false");
expect(automaticInstallCheckbox).toHaveAttribute("aria-disabled", "true");
expect(selfServiceSwitch.disabled).toBe(false);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("disables checkboxes when disableOptions is true", () => {
it("disables sliders when disableOptions is true", () => {
renderComponent({ disableOptions: true });
const selfServiceCheckbox = screen
.getByText("Self-service")
.closest('[role="checkbox"]');
const automaticInstallCheckbox = screen
.getByText("Automatic install")
.closest('[role="checkbox"]');
const selfServiceSwitch = getSwitchByLabelText("Self-service");
const automaticInstallSwitch = getSwitchByLabelText("Automatic install");
expect(selfServiceCheckbox).toHaveAttribute("aria-disabled", "true");
expect(automaticInstallCheckbox).toHaveAttribute("aria-disabled", "true");
expect(selfServiceSwitch.disabled).toBe(true);
expect(automaticInstallSwitch.disabled).toBe(true);
});
it("renders the InfoBanner when automaticInstall is true and isCustomPackage is true", () => {
@ -144,7 +128,7 @@ describe("SoftwareOptionsSelector", () => {
).not.toBeInTheDocument();
});
it("does not render automatic install checkbox when isEditingSoftware is true", () => {
it("does not render automatic install slider when isEditingSoftware is true", () => {
renderComponent({ isEditingSoftware: true });
expect(screen.queryByText("Automatic install")).not.toBeInTheDocument();
@ -166,7 +150,7 @@ describe("SoftwareOptionsSelector", () => {
).toBeInTheDocument();
});
it("does not render automatic install checkbox in edit mode", () => {
it("does not render automatic install slider in edit mode", () => {
renderComponent({ isEditingSoftware: true });
expect(screen.queryByText("Automatic install")).not.toBeInTheDocument();

View file

@ -2,6 +2,7 @@ import React from "react";
import classnames from "classnames";
import Checkbox from "components/forms/fields/Checkbox";
import Slider from "components/forms/fields/Slider";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
@ -18,6 +19,7 @@ import {
} from "pages/hosts/details/cards/Software/SelfService/helpers";
import Button from "components/buttons/Button";
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "software-options-selector";
@ -70,8 +72,8 @@ interface ISoftwareOptionsSelector {
| IPackageFormData
| ISoftwareAndroidFormData;
/** Only used in create mode not edit mode for FMA, VPP, and custom packages */
onToggleAutomaticInstall: (value: boolean) => void;
onToggleSelfService: (value: boolean) => void;
onToggleAutomaticInstall: () => void;
onToggleSelfService: () => void;
onClickPreviewEndUserExperience: () => void;
onSelectCategory: ({ name, value }: { name: string; value: boolean }) => void;
platform?: string;
@ -181,24 +183,45 @@ const SoftwareOptionsSelector = ({
) : null;
};
const selfServiceLabel = () => {
return !isSelfServiceDisabled ? (
<TooltipWrapper
tipContent={getSelfServiceTooltip(
isPlatformIosOrIpados,
isPlatformAndroid
)}
>
<span>Self-service</span>
</TooltipWrapper>
) : (
"Self-service"
);
};
const automaticInstallLabel = () => {
return showAutomaticInstallTooltip ? (
<TooltipWrapper tipContent={getAutomaticInstallTooltip()}>
<span>Automatic install</span>
</TooltipWrapper>
) : (
"Automatic install"
);
};
return (
<div className={`form-field ${classNames}`}>
<div className="form-field__label">Options</div>
{renderOptionsDescription()}
<div className={`${baseClass}__self-service`}>
<Checkbox
<Slider
value={formData.selfService}
onChange={(newVal: boolean) => onToggleSelfService(newVal)}
className={`${baseClass}__self-service-checkbox`}
labelTooltipContent={
!isSelfServiceDisabled &&
getSelfServiceTooltip(isPlatformIosOrIpados, isPlatformAndroid)
}
labelTooltipClickable // Allow interaction with link in tooltip
onChange={onToggleSelfService}
inactiveText={selfServiceLabel()}
activeText={selfServiceLabel()}
className={`${baseClass}__self-service-slider`}
disabled={isSelfServiceDisabled}
>
Self-service
</Checkbox>
/>
{canSelectSoftwareCategories && (
<CategoriesSelector
onSelectCategory={onSelectCategory}
@ -208,17 +231,14 @@ const SoftwareOptionsSelector = ({
)}
</div>
{!isEditingSoftware && (
<Checkbox
<Slider
value={formData.automaticInstall}
onChange={(newVal: boolean) => onToggleAutomaticInstall(newVal)}
className={`${baseClass}__automatic-install-checkbox`}
labelTooltipContent={
showAutomaticInstallTooltip && getAutomaticInstallTooltip()
}
onChange={onToggleAutomaticInstall}
activeText={automaticInstallLabel()}
inactiveText={automaticInstallLabel()}
className={`${baseClass}__automatic-install-slider`}
disabled={isAutomaticInstallDisabled}
>
Automatic install
</Checkbox>
/>
)}
{formData.automaticInstall && isCustomPackage && (
<InfoBanner color="yellow">

View file

@ -181,8 +181,8 @@ const SoftwareVppForm = ({
}
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
const onToggleSelfService = () => {
const newData = { ...formData, selfService: !formData.selfService };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
@ -215,8 +215,11 @@ const SoftwareVppForm = ({
setFormValidation(generateFormValidation(newData));
};
const onToggleAutomaticInstall = (value: boolean) => {
const newData = { ...formData, automaticInstall: value };
const onToggleAutomaticInstall = () => {
const newData = {
...formData,
automaticInstall: !formData.automaticInstall,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
@ -259,40 +262,36 @@ const SoftwareVppForm = ({
canEdit={false}
/>
<div className={`${baseClass}__form-frame`}>
<Card paddingSize="medium" borderRadiusSize="medium">
<SoftwareOptionsSelector
platform={softwareVppForEdit.platform}
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfServiceCheckbox}
onSelectCategory={onSelectCategory}
isEditingSoftware
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isAppleMobile)
}
/>
</Card>
<Card paddingSize="medium" borderRadiusSize="medium">
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
generateHelpText(false, formData.customTarget) // maps to !automaticInstall help text
}
subTitle={
isAppleMobile
? "Changing this will also apply to targets for auto-updates."
: ""
}
/>
</Card>
<SoftwareOptionsSelector
platform={softwareVppForEdit.platform}
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
isEditingSoftware
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(isAppleMobile)
}
/>
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
labels={labels || []}
dropdownHelpText={
generateHelpText(false, formData.customTarget) // maps to !automaticInstall help text
}
subTitle={
isAppleMobile
? "Changing this will also apply to targets for auto-updates."
: ""
}
/>
</div>
</div>
);
@ -323,7 +322,7 @@ const SoftwareVppForm = ({
}
formData={formData}
onToggleAutomaticInstall={onToggleAutomaticInstall}
onToggleSelfService={onToggleSelfServiceCheckbox}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(

View file

@ -215,12 +215,14 @@ func TestValidGitOpsYaml(t *testing.T) {
switch fma.Slug {
case "slack/darwin":
require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Communication"})
require.Equal(t, "4.47.65", fma.Version)
require.Empty(t, fma.PreInstallQuery)
require.Empty(t, fma.PostInstallScript)
require.Empty(t, fma.InstallScript)
require.Empty(t, fma.UninstallScript)
case "box-drive/windows":
require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Developer tools"})
require.Empty(t, fma.Version)
require.NotEmpty(t, fma.PreInstallQuery)
require.NotEmpty(t, fma.PostInstallScript)
require.NotEmpty(t, fma.InstallScript)

View file

@ -59,6 +59,7 @@ software:
- a
fleet_maintained_apps:
- slug: slack/darwin
version: "4.47.65"
self_service: true
categories:
- Productivity

View file

@ -163,6 +163,7 @@ software:
- a
fleet_maintained_apps:
- slug: slack/darwin
version: "4.47.65"
self_service: true
categories:
- Productivity

View file

@ -59,6 +59,7 @@ software:
setup_experience: true
fleet_maintained_apps:
- slug: slack/darwin
version: "4.47.65"
self_service: true
categories:
- Productivity

View file

@ -0,0 +1,36 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260218175704, Down_20260218175704)
}
func Up_20260218175704(tx *sql.Tx) error {
_, err := tx.Exec(`
ALTER TABLE software_installers
DROP INDEX idx_software_installers_team_id_title_id,
DROP INDEX idx_software_installers_platform_title_id,
ADD UNIQUE INDEX idx_software_installers_team_title_version (global_or_team_id,title_id,version),
ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 0
`)
if err != nil {
return fmt.Errorf("altering software_installers: %w", err)
}
// At migration time, the 1-installer-per-title rule is still enforced,
// so every existing installer is the active one for its title.
_, err = tx.Exec(`UPDATE software_installers SET is_active = 1`)
if err != nil {
return fmt.Errorf("setting is_active for existing installers: %w", err)
}
return nil
}
func Down_20260218175704(tx *sql.Tx) error {
return nil
}

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"fmt"
"maps"
"slices"
"strings"
"time"
@ -321,8 +323,9 @@ INSERT INTO software_installers (
user_email,
fleet_maintained_app_id,
url,
upgrade_code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, ?)`
upgrade_code,
is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, ?, ?)`
args := []interface{}{
tid,
@ -345,6 +348,7 @@ INSERT INTO software_installers (
payload.FleetMaintainedAppID,
payload.URL,
payload.UpgradeCode,
true,
}
res, err := tx.ExecContext(ctx, stmt, args...)
@ -1007,7 +1011,10 @@ 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 = ?`,
si.title_id = ? AND si.global_or_team_id = ?
AND si.is_active = 1
ORDER BY si.uploaded_at DESC, si.id DESC
LIMIT 1`,
scriptContentsSelect, scriptContentsFrom)
var tmID uint
@ -2020,6 +2027,8 @@ func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwa
return ctxerr.Wrap(ctx, err, "cleanup unused software installers")
}
const maxCachedFMAVersions = 2
func (ds *Datastore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
const upsertSoftwareTitles = `
INSERT INTO software_titles
@ -2260,10 +2269,11 @@ INSERT INTO software_installers (
url,
package_ids,
install_during_setup,
fleet_maintained_app_id
fleet_maintained_app_id,
is_active
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
(SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ?
(SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ?, ?
)
ON DUPLICATE KEY UPDATE
install_script_content_id = VALUES(install_script_content_id),
@ -2281,7 +2291,8 @@ ON DUPLICATE KEY UPDATE
user_name = VALUES(user_name),
user_email = VALUES(user_email),
url = VALUES(url),
install_during_setup = COALESCE(?, install_during_setup)
install_during_setup = COALESCE(?, install_during_setup),
is_active = VALUES(is_active)
`
const loadSoftwareInstallerID = `
@ -2291,8 +2302,9 @@ FROM
software_installers
WHERE
global_or_team_id = ? AND
-- this is guaranteed to select a single title_id, due to unique index
title_id = ?
ORDER BY uploaded_at DESC, id DESC
LIMIT 1
`
const deleteInstallerLabelsNotInList = `
@ -2669,6 +2681,12 @@ WHERE
}
}
// Non-FMA installers are always active, FMA installers start inactive and are activated later
isActive := 0
if installer.FleetMaintainedAppID == nil {
isActive = 1
}
args := []interface{}{
tmID,
globalOrTeamID,
@ -2691,15 +2709,36 @@ WHERE
strings.Join(installer.PackageIDs, ","),
installer.InstallDuringSetup,
installer.FleetMaintainedAppID,
isActive,
installer.InstallDuringSetup,
}
upsertQuery := insertNewOrEditedInstaller
if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package
upsertQuery = fmt.Sprintf("%s, uploaded_at = NOW()", upsertQuery)
// For FMA installers, skip the insert if this exact version is already cached
// for this team+title. This prevents duplicate rows from repeated batch sets
// that re-download the same latest version.
var skipInsert bool
if installer.FleetMaintainedAppID != nil {
var existingID uint
err := sqlx.GetContext(ctx, tx, &existingID, `
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND version = ?
LIMIT 1
`, globalOrTeamID, titleID, installer.Version)
if err == nil {
skipInsert = true
} else if !errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrapf(ctx, err, "check existing FMA version %q for %q", installer.Version, installer.Filename)
}
}
if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename)
if !skipInsert {
upsertQuery := insertNewOrEditedInstaller
if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package
upsertQuery = fmt.Sprintf("%s, uploaded_at = NOW()", upsertQuery)
}
if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename)
}
}
// now that the software installer is created/updated, load its installer
@ -2710,6 +2749,89 @@ WHERE
return ctxerr.Wrapf(ctx, err, "load id of new/edited installer with name %q", installer.Filename)
}
// For non-FMA (custom) packages, enforce one installer per title per team.
// With the unique constraint on (global_or_team_id, title_id, version),
// a version change inserts a new row instead of replacing — clean up the old one.
if installer.FleetMaintainedAppID == nil {
if _, err := tx.ExecContext(ctx, `
DELETE FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND id != ?
`, globalOrTeamID, titleID, installerID); err != nil {
return ctxerr.Wrapf(ctx, err, "clean up old versions of custom installer %q", installer.Filename)
}
}
// For FMA installers: determine the active version, then evict old versions
// (protecting the active one from eviction).
if installer.FleetMaintainedAppID != nil {
// Determine which installer should be "active" for this FMA+team.
// If RollbackVersion is specified, find the cached installer with that version;
// otherwise default to the newest (just inserted/updated).
activeInstallerID := installerID
if installer.RollbackVersion != "" {
var pinnedID uint
err := sqlx.GetContext(ctx, tx, &pinnedID, `
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND version = ?
ORDER BY uploaded_at DESC, id DESC LIMIT 1
`, globalOrTeamID, titleID, installer.RollbackVersion)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't edit %q: specified version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.",
installer.Filename,
),
})
}
return ctxerr.Wrapf(ctx, err, "find cached FMA installer version %q for %q", installer.RollbackVersion, installer.Filename)
}
activeInstallerID = pinnedID
}
// Evict old FMA versions beyond the max per title per team.
// Always keep the active installer; fill remaining slots with
// the most recent versions, evict everything else.
fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, tx, []uint{titleID}, globalOrTeamID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "list FMA installer versions for eviction for %q", installer.Filename)
}
versions := fmaVersions[titleID]
if len(versions) > maxCachedFMAVersions {
// Build the keep set: active installer + most recent up to max.
keepSet := map[uint]bool{activeInstallerID: true}
for _, v := range versions {
if len(keepSet) >= maxCachedFMAVersions {
break
}
keepSet[v.ID] = true
}
stmt, args, err := sqlx.In(
`DELETE FROM software_installers WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND id NOT IN (?)`,
globalOrTeamID,
titleID,
slices.Collect(maps.Keys(keepSet)),
)
if err != nil {
return ctxerr.Wrap(ctx, err, "build FMA eviction query")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "evict old FMA versions for %q", installer.Filename)
}
}
// Update the active installer and set all others to inactive
if _, err := tx.ExecContext(ctx, `
UPDATE software_installers
SET is_active = (id = ?)
WHERE global_or_team_id = ? AND fleet_maintained_app_id = ?
`, activeInstallerID, globalOrTeamID, installer.FleetMaintainedAppID); err != nil {
return ctxerr.Wrapf(ctx, err, "setting active installer for %q", installer.Filename)
}
}
// process the labels associated with that software installer
if len(installer.ValidatedLabels.ByName) == 0 {
// no label to apply, so just delete all existing labels if any
@ -3369,7 +3491,9 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay
}
}
// check if an in-house app with the same bundle id already exists.
// Check if an in-house app with the same bundle id already exists.
// Also check if equivalent installers exist, since we relaxed the uniqueness constraints to allow
// multiple FMA installer versions.
if payload.BundleIdentifier != "" {
exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInHouseApp)
if err != nil {
@ -3378,6 +3502,24 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay
if exists {
return alreadyExists("in-house app", payload.Title)
}
exists, err = ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInstaller)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier")
}
if exists {
return alreadyExists("installer", payload.Title)
}
}
if payload.Platform == "windows" {
exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Platform, softwareTypeInstaller)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier")
}
if exists {
return alreadyExists("installer", payload.Title)
}
}
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
@ -65,7 +66,7 @@ SELECT
FROM software_titles st
%s
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.hosts_count > 0 AND (%s)
LEFT JOIN software_installers si ON si.title_id = st.id AND %s
LEFT JOIN software_installers si ON si.title_id = st.id AND si.is_active = TRUE AND %s
LEFT JOIN vpp_apps vap ON vap.title_id = st.id
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s
LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND %s
@ -117,6 +118,7 @@ GROUP BY
}
title.VersionsCount = uint(len(title.Versions))
return &title, nil
}
@ -422,6 +424,30 @@ func (ds *Datastore) ListSoftwareTitles(
}
}
// Populate FleetMaintainedVersions for titles that have a fleet-maintained app.
// This must happen before pagination trimming so titleIndex is still valid.
if opt.TeamID != nil {
var fmaTitleIDs []uint
for _, st := range softwareList {
if st.FleetMaintainedAppID != nil {
fmaTitleIDs = append(fmaTitleIDs, st.ID)
}
}
if len(fmaTitleIDs) > 0 {
fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), fmaTitleIDs, *opt.TeamID)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions")
}
for titleID, versions := range fmaVersions {
if i, ok := titleIndex[titleID]; ok {
if softwareList[i].SoftwarePackage != nil {
softwareList[i].SoftwarePackage.FleetMaintainedVersions = versions
}
}
}
}
}
var metaData *fleet.PaginationMetadata
if opt.ListOptions.IncludeMetadata {
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.ListOptions.Page > 0}
@ -506,7 +532,7 @@ SELECT
{{end}}
FROM software_titles st
{{if hasTeamID .}}
LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}}
LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}} AND si.is_active = TRUE
LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = {{teamID .}}
LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}}
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND
@ -671,6 +697,110 @@ GROUP BY
return buff.String(), args, nil
}
// GetFleetMaintainedVersionsByTitleID returns all cached versions of a fleet-maintained app
// for the given title and team.
func (ds *Datastore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error) {
result, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), []uint{titleID}, ptr.ValOrZero(teamID))
if err != nil {
return nil, err
}
return result[titleID], nil
}
// getFleetMaintainedVersionsByTitleIDs returns all cached versions of fleet-maintained apps
// for the given title IDs and team, keyed by title ID.
func (ds *Datastore) getFleetMaintainedVersionsByTitleIDs(ctx context.Context, q sqlx.QueryerContext, titleIDs []uint, teamID uint) (map[uint][]fleet.FleetMaintainedVersion, error) {
if len(titleIDs) == 0 {
return nil, nil
}
query, args, err := sqlx.In(`
SELECT si.id, si.version, si.title_id
FROM software_installers si
WHERE si.title_id IN (?) AND si.global_or_team_id = ? AND si.fleet_maintained_app_id IS NOT NULL
ORDER BY si.title_id, si.uploaded_at DESC
`, titleIDs, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build fleet maintained versions query")
}
type fmaVersionRow struct {
fleet.FleetMaintainedVersion
TitleID uint `db:"title_id"`
}
var rows []fmaVersionRow
if err := sqlx.SelectContext(ctx, q, &rows, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select fleet maintained versions")
}
result := make(map[uint][]fleet.FleetMaintainedVersion, len(titleIDs))
for _, row := range rows {
result[row.TitleID] = append(result[row.TitleID], row.FleetMaintainedVersion)
}
return result, nil
}
func (ds *Datastore) HasFMAInstallerVersion(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error) {
var exists bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, `
SELECT EXISTS(
SELECT 1 FROM software_installers
WHERE global_or_team_id = ? AND fleet_maintained_app_id = ? AND version = ?
LIMIT 1
)
`, ptr.ValOrZero(teamID), fmaID, version)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "check FMA installer version exists")
}
return exists, nil
}
func (ds *Datastore) GetCachedFMAInstallerMetadata(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error) {
globalOrTeamID := ptr.ValOrZero(teamID)
var result fleet.MaintainedApp
err := sqlx.GetContext(ctx, ds.reader(ctx), &result, `
SELECT
si.version,
si.platform,
si.url,
si.storage_id,
COALESCE(isc.contents, '') AS install_script,
COALESCE(usc.contents, '') AS uninstall_script,
COALESCE(si.pre_install_query, '') AS pre_install_query,
si.upgrade_code
FROM software_installers si
LEFT JOIN script_contents isc ON isc.id = si.install_script_content_id
LEFT JOIN script_contents usc ON usc.id = si.uninstall_script_content_id
WHERE si.global_or_team_id = ? AND si.fleet_maintained_app_id = ? AND si.version = ?
LIMIT 1
`, globalOrTeamID, fmaID, version)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("CachedFMAInstaller")
}
return nil, ctxerr.Wrap(ctx, err, "get cached FMA installer metadata")
}
// Load categories
var categories []string
err = sqlx.SelectContext(ctx, ds.reader(ctx), &categories, `
SELECT sc.name FROM software_categories sc
JOIN software_installer_software_categories sisc ON sisc.software_category_id = sc.id
JOIN software_installers si ON si.id = sisc.software_installer_id
WHERE si.global_or_team_id = ? AND si.fleet_maintained_app_id = ? AND si.version = ?
`, globalOrTeamID, fmaID, version)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get cached FMA installer categories")
}
result.Categories = categories
return &result, nil
}
func (ds *Datastore) selectSoftwareVersionsSQL(titleIDs []uint, teamID *uint, tmFilter fleet.TeamFilter, withCounts bool) (
string, []any, error,
) {

View file

@ -2068,6 +2068,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)
// GetFleetMaintainedVersionsByTitleID returns all cached versions of a
// fleet-maintained app for the given title and team.
GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]FleetMaintainedVersion, error)
// HasFMAInstallerVersion returns true if the given FMA version is already
// cached as a software installer for the given team.
HasFMAInstallerVersion(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error)
// GetCachedFMAInstallerMetadata returns the cached metadata for a specific
// FMA installer version, including install/uninstall scripts, URL, SHA256,
// etc. Returns a NotFoundError if no cached installer exists for the given
// version.
GetCachedFMAInstallerMetadata(ctx context.Context, teamID *uint, fmaID uint, version string) (*MaintainedApp, error)
InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID, softwareTitleID uint, commandUUID string, opts HostSoftwareInstallOptions) error
// GetSoftwareInstallersPendingUninstallScriptPopulation returns a map of software installers to storage IDs that:

View file

@ -7,17 +7,17 @@ type MaintainedApp struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Version string `json:"version,omitempty"`
Version string `json:"version,omitempty" db:"version"`
Platform string `json:"platform" db:"platform"`
TitleID *uint `json:"software_title_id" db:"software_title_id"`
InstallerURL string `json:"url,omitempty"`
SHA256 string `json:"-"`
InstallerURL string `json:"url,omitempty" db:"url"`
SHA256 string `json:"-" db:"storage_id"`
UniqueIdentifier string `json:"-" db:"unique_identifier"`
InstallScript string `json:"install_script,omitempty"`
UninstallScript string `json:"uninstall_script,omitempty"`
AutomaticInstallQuery string `json:"-"`
InstallScript string `json:"install_script,omitempty" db:"install_script"`
UninstallScript string `json:"uninstall_script,omitempty" db:"uninstall_script"`
AutomaticInstallQuery string `json:"-" db:"pre_install_query"`
Categories []string `json:"categories"`
UpgradeCode string `json:"upgrade_code,omitempty"`
UpgradeCode string `json:"upgrade_code,omitempty" db:"upgrade_code"`
}
func (s *MaintainedApp) Source() string {

View file

@ -441,8 +441,9 @@ type SoftwareInstallerPayload struct {
Categories []string `json:"categories"`
DisplayName string `json:"display_name"`
// This is to support FMAs
Slug *string `json:"slug"`
MaintainedApp *MaintainedApp `json:"-"`
Slug *string `json:"slug"`
MaintainedApp *MaintainedApp `json:"-"`
RollbackVersion string `json:"fleet_maintained_app_version"`
IconPath string `json:"-"`
IconHash string `json:"-"`

View file

@ -360,6 +360,14 @@ type SoftwareAutoUpdateScheduleFilter struct {
Enabled *bool
}
// FleetMaintainedVersion represents a cached version of a Fleet-maintained app.
type FleetMaintainedVersion struct {
// ID is the ID of the software installer for this version.
ID uint `json:"id" db:"id"`
// Version is the version string.
Version string `json:"version" db:"version"`
}
// SoftwareTitle represents a title backed by the `software_titles` table.
type SoftwareTitle struct {
ID uint `json:"id" db:"id"`

View file

@ -113,7 +113,8 @@ type SoftwareInstaller struct {
// URL is the source URL for this installer (set when uploading via batch/gitops).
URL string `json:"url" db:"url"`
// FleetMaintainedAppID is the related Fleet-maintained app for this installer (if not nil).
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
FleetMaintainedVersions []FleetMaintainedVersion `json:"fleet_maintained_versions,omitempty"`
// AutomaticInstallPolicies is the list of policies that trigger automatic
// installation of this software.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
@ -507,13 +508,19 @@ type UploadSoftwareInstallerPayload struct {
UserID uint
URL string
FleetMaintainedAppID *uint
PackageIDs []string
UpgradeCode string
UninstallScript string
Extension string
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
// RollbackVersion is the version to pin as "active" for a fleet-maintained app.
// If empty, the latest version is used.
RollbackVersion string
// FMAVersionCached indicates this FMA version is already cached in the
// database and installer store, so storage and insert can be skipped.
FMAVersionCached bool
PackageIDs []string
UpgradeCode string
UninstallScript string
Extension string
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
// is nil if the labels have not been validated.
ValidatedLabels *LabelIdentsWithScope
@ -739,9 +746,10 @@ type SoftwarePackageOrApp struct {
PackageURL *string `json:"package_url"`
// InstallDuringSetup is a boolean that indicates if the package
// will be installed during the macos setup experience.
InstallDuringSetup *bool `json:"install_during_setup,omitempty" db:"install_during_setup"`
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id,omitempty" db:"fleet_maintained_app_id"`
Categories []string `json:"categories,omitempty"`
InstallDuringSetup *bool `json:"install_during_setup,omitempty" db:"install_during_setup"`
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id,omitempty" db:"fleet_maintained_app_id"`
FleetMaintainedVersions []FleetMaintainedVersion `json:"fleet_maintained_versions,omitempty"`
Categories []string `json:"categories,omitempty"`
}
func (s *SoftwarePackageOrApp) GetPlatform() string {
@ -776,7 +784,8 @@ type SoftwarePackageSpec struct {
Icon TeamSpecSoftwareAsset `json:"icon"`
// FMA
Slug *string `json:"slug"`
Slug *string `json:"slug"`
Version string `json:"version"`
// ReferencedYamlPath is the resolved path of the file used to fill the
// software package. Only present after parsing a GitOps file on the fleetctl
@ -817,6 +826,7 @@ func resolveApplyRelativePath(baseDir string, path string) string {
type MaintainedAppSpec struct {
Slug string `json:"slug"`
Version string `json:"version"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
@ -832,6 +842,7 @@ type MaintainedAppSpec struct {
func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec {
return SoftwarePackageSpec{
Slug: &spec.Slug,
Version: spec.Version,
PreInstallQuery: spec.PreInstallQuery,
InstallScript: spec.InstallScript,
PostInstallScript: spec.PostInstallScript,

View file

@ -115,8 +115,53 @@ func upsertMaintainedApps(ctx context.Context, appsList *AppsList, ds fleet.Data
return nil
}
// Hydrate pulls information from app-level FMA manifests info an FMA skeleton pulled from the database
func Hydrate(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error) {
// FMAInstallerCache is an optional interface for looking up cached FMA installer
// metadata from the database. When provided to Hydrate with a target version,
// it allows skipping the remote manifest fetch if the version is already cached.
type FMAInstallerCache interface {
GetCachedFMAInstallerMetadata(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error)
}
// Hydrate pulls information from app-level FMA manifests into an FMA skeleton
// pulled from the database. If version is non-empty and cache is provided, it
// loads the metadata from the local cache, returning an error if the version is
// not cached. If no version is specified, it fetches the latest from the remote manifest.
func Hydrate(ctx context.Context, app *fleet.MaintainedApp, version string, teamID *uint, cache FMAInstallerCache) (*fleet.MaintainedApp, error) {
if version != "" && cache == nil {
return nil, ctxerr.New(ctx, "no fma version cache provided")
}
// If a specific version is requested and we have a cache, try the cache first.
if version != "" && cache != nil {
cached, err := cache.GetCachedFMAInstallerMetadata(ctx, teamID, app.ID, version)
if err != nil {
if fleet.IsNotFound(err) {
// Version not found in cache - return the same error as BatchSetSoftwareInstallers
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't edit %q: specified version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.",
app.Name,
),
})
}
return nil, ctxerr.Wrap(ctx, err, "get cached FMA installer metadata")
}
// Copy installer-level fields from cache onto the app,
// preserving the app-level fields (ID, Name, Slug, etc.)
// that were already loaded from the database.
app.Version = cached.Version
app.Platform = cached.Platform
app.InstallerURL = cached.InstallerURL
app.SHA256 = cached.SHA256
app.InstallScript = cached.InstallScript
app.UninstallScript = cached.UninstallScript
app.AutomaticInstallQuery = cached.AutomaticInstallQuery
app.Categories = cached.Categories
app.UpgradeCode = cached.UpgradeCode
return app, nil
}
httpClient := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
baseURL := fmaOutputsBase
if baseFromEnvVar := dev_mode.Env("FLEET_DEV_MAINTAINED_APPS_BASE_URL"); baseFromEnvVar != "" {

View file

@ -1335,6 +1335,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 GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error)
type HasFMAInstallerVersionFunc func(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error)
type GetCachedFMAInstallerMetadataFunc func(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error)
type InsertHostInHouseAppInstallFunc func(ctx context.Context, hostID uint, inHouseAppID uint, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error
type GetSoftwareInstallersPendingUninstallScriptPopulationFunc func(ctx context.Context) (map[uint]string, error)
@ -3746,6 +3752,15 @@ type DataStore struct {
GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc
GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool
GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc
GetFleetMaintainedVersionsByTitleIDFuncInvoked bool
HasFMAInstallerVersionFunc HasFMAInstallerVersionFunc
HasFMAInstallerVersionFuncInvoked bool
GetCachedFMAInstallerMetadataFunc GetCachedFMAInstallerMetadataFunc
GetCachedFMAInstallerMetadataFuncInvoked bool
InsertHostInHouseAppInstallFunc InsertHostInHouseAppInstallFunc
InsertHostInHouseAppInstallFuncInvoked bool
@ -9004,6 +9019,27 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con
return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents)
}
func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error) {
s.mu.Lock()
s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true
s.mu.Unlock()
return s.GetFleetMaintainedVersionsByTitleIDFunc(ctx, teamID, titleID)
}
func (s *DataStore) HasFMAInstallerVersion(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error) {
s.mu.Lock()
s.HasFMAInstallerVersionFuncInvoked = true
s.mu.Unlock()
return s.HasFMAInstallerVersionFunc(ctx, teamID, fmaID, version)
}
func (s *DataStore) GetCachedFMAInstallerMetadata(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error) {
s.mu.Lock()
s.GetCachedFMAInstallerMetadataFuncInvoked = true
s.mu.Unlock()
return s.GetCachedFMAInstallerMetadataFunc(ctx, teamID, fmaID, version)
}
func (s *DataStore) InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID uint, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error {
s.mu.Lock()
s.InsertHostInHouseAppInstallFuncInvoked = true

View file

@ -1276,6 +1276,7 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
if si.Slug != nil {
softwarePayloads[i].Slug = si.Slug
softwarePayloads[i].RollbackVersion = si.Version
}
}

View file

@ -14307,9 +14307,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP
_, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(title_id, filename, extension, version, platform, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query, package_ids)
(title_id, filename, extension, version, platform, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query, package_ids, is_active)
VALUES
(?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?, ?, ?)`,
(?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?, ?, ?, TRUE)`,
titleID, fmt.Sprintf("installer.%s", kind), kind, "v1.0.0", platform, scriptContentID, uninstallScriptContentID,
hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo", "")
return err
@ -19550,7 +19550,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
// TODO this will change when actual install scripts are created.
dbAppRecord, err := s.ds.GetMaintainedAppByID(ctx, listMAResp.FleetMaintainedApps[0].ID, nil)
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, dbAppRecord)
_, err = maintained_apps.Hydrate(ctx, dbAppRecord, "", nil, nil)
require.NoError(t, err)
dbAppResponse := fleet.MaintainedApp{
ID: dbAppRecord.ID,
@ -19628,6 +19628,31 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.Nil(t, addMAResp.Err)
// Verify the installer has is_active = 1 for this FMA
var activeInstallerCount, onlyInstallerID uint
var isActive bool
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
err := sqlx.GetContext(ctx, q, &activeInstallerCount,
`SELECT COUNT(*) FROM software_installers WHERE fleet_maintained_app_id = ? AND global_or_team_id = ? AND is_active = 1`,
req.AppID, team.ID)
if err != nil {
return err
}
err = sqlx.GetContext(ctx, q, &onlyInstallerID,
`SELECT id FROM software_installers WHERE fleet_maintained_app_id = ? AND global_or_team_id = ?`,
req.AppID, team.ID)
if err != nil {
return err
}
return sqlx.GetContext(ctx, q, &isActive,
`SELECT is_active FROM software_installers WHERE id = ?`,
onlyInstallerID)
})
require.True(t, isActive, "installer must have is_active = 1")
require.Equal(t, uint(1), activeInstallerCount, "single-add API must set is_active = 1 for the installer")
s.DoJSON(http.MethodGet, "/api/latest/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsRequest{}, http.StatusOK,
&listMAResp, "team_id", fmt.Sprint(team.ID))
require.Nil(t, listMAResp.Err)
@ -19637,7 +19662,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
// Validate software installer fields
mapp, err := s.ds.GetMaintainedAppByID(ctx, 1, &team.ID)
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, mapp)
_, err = maintained_apps.Hydrate(ctx, mapp, "", nil, nil)
require.NoError(t, err)
i, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), getSoftwareInstallerIDByMAppID(1))
require.NoError(t, err)
@ -19728,7 +19753,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
mapp, err = s.ds.GetMaintainedAppByID(ctx, 4, ptr.Uint(0))
require.NoError(t, err)
_, err = maintained_apps.Hydrate(ctx, mapp)
_, err = maintained_apps.Hydrate(ctx, mapp, "", nil, nil)
require.NoError(t, err)
require.Equal(t, 1, resp.Count)
title = resp.SoftwareTitles[0]
@ -25427,3 +25452,916 @@ func (s *integrationEnterpriseTestSuite) TestUpdateSoftwareAutoUpdateConfig() {
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":"adam_vpp_app_1", "auto_update_enabled":false, "platform":"ipados", "self_service":false, "software_display_name":"Updated Display Name", "software_icon_url":null, "software_title":"vpp1", "software_title_id":%d, "team_id":%d, "team_name":"%s", "fleet_id":%d, "fleet_name":"%s"}`, vppApp.TitleID, team.ID, team.Name, team.ID, team.Name), 0)
}
func (s *integrationEnterpriseTestSuite) TestFMAVersionRollback() {
t := s.T()
ctx := context.Background()
// --- Shared per-FMA state for mock servers ---
// Each FMA (warp, zoom) has independently mutable version/bytes/sha so the
// manifest and installer servers can serve different content per slug.
type fmaTestState struct {
version string
installerBytes []byte
sha256 string
}
computeSHA := func(b []byte) string {
h := sha256.New()
h.Write(b)
return hex.EncodeToString(h.Sum(nil))
}
warpState := &fmaTestState{version: "1.0", installerBytes: []byte("abc")}
warpState.sha256 = computeSHA(warpState.installerBytes)
zoomState := &fmaTestState{version: "1.0", installerBytes: []byte("xyz")}
zoomState.sha256 = computeSHA(zoomState.installerBytes)
// downloadedSlugs tracks which FMA slugs were hit on the installer server
// so individual tests can assert whether a download occurred.
downloadedSlugs := map[string]bool{}
var downloadMu sync.Mutex
// Mock installer server — routes by path to serve per-FMA bytes.
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
downloadMu.Lock()
defer downloadMu.Unlock()
switch r.URL.Path {
case "/cloudflare-warp.msi":
downloadedSlugs["cloudflare-warp/windows"] = true
_, _ = w.Write(warpState.installerBytes)
case "/zoom.msi":
downloadedSlugs["zoom/windows"] = true
_, _ = w.Write(zoomState.installerBytes)
default:
http.NotFound(w, r)
}
}))
defer installerServer.Close()
// Non-existent maintained app
s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{AppID: 1}, http.StatusNotFound)
// Insert the list of maintained apps
maintained_apps.SyncApps(t, s.ds)
// Mock manifest server — routes by slug path and returns current per-FMA state.
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var state *fmaTestState
var installerPath string
switch r.URL.Path {
case "/cloudflare-warp/windows.json":
state = warpState
installerPath = "/cloudflare-warp.msi"
case "/zoom/windows.json":
state = zoomState
installerPath = "/zoom.msi"
default:
http.NotFound(w, r)
return
}
versions := []*ma.FMAManifestApp{
{
Version: state.version,
Queries: ma.FMAQueries{Exists: "SELECT 1 FROM osquery_info;"},
InstallerURL: installerServer.URL + installerPath,
InstallScriptRef: "foobaz",
UninstallScriptRef: "foobaz",
SHA256: state.sha256,
DefaultCategories: []string{"Productivity"},
},
}
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{"foobaz": "Hello World!"},
}
require.NoError(t, json.NewEncoder(w).Encode(manifest))
}))
t.Cleanup(manifestServer.Close)
dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL)
defer dev_mode.ClearOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL")
// -------------------------------------------------------------------------
// Sub-test helper: fetch the active software title for a team by FMA name.
// -------------------------------------------------------------------------
getActiveTitleForTeam := func(teamID uint) fleet.SoftwareTitleListResult {
var resp listSoftwareTitlesResponse
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"per_page", "1",
"order_key", "name",
"order_direction", "desc",
"available_for_install", "true",
"team_id", fmt.Sprintf("%d", teamID),
)
require.Equal(t, 1, resp.Count)
return resp.SoftwareTitles[0]
}
// -------------------------------------------------------------------------
// Shared helpers used across sub-tests.
// -------------------------------------------------------------------------
// newTeam creates a new team with the given name and returns it.
newTeam := func(name string) fleet.Team {
var resp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{
TeamPayload: fleet.TeamPayload{Name: ptr.String(name)},
}, http.StatusOK, &resp)
return *resp.Team
}
// resetFMAState resets an fmaTestState to the given version and installer bytes.
resetFMAState := func(state *fmaTestState, version string, installerBytes []byte) {
state.version = version
state.installerBytes = installerBytes
state.sha256 = computeSHA(installerBytes)
}
// batchSet issues a batch-set request for the given team and software slice,
// waits for completion, and returns the resulting packages.
batchSet := func(team fleet.Team, software []*fleet.SoftwareInstallerPayload) []fleet.SoftwarePackageResponse {
var resp batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch",
batchSetSoftwareInstallersRequest{Software: software, TeamName: team.Name},
http.StatusAccepted, &resp,
"team_name", team.Name, "team_id", fmt.Sprint(team.ID),
)
return waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, team.Name, resp.RequestUUID)
}
// newHostInTeam creates an orbit-enrolled host and assigns it to the given team.
newHostInTeam := func(platform, name string, team fleet.Team) *fleet.Host {
host := createOrbitEnrolledHost(t, platform, name, s.ds)
require.NoError(t, s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})))
return host
}
// fmaAppID returns the fleet_maintained_apps.id for the given slug.
fmaAppID := func(slug string) uint {
var id uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, "SELECT id FROM fleet_maintained_apps WHERE slug = ?", slug)
})
require.NotZero(t, id)
return id
}
// =========================================================================
// Section 1 (existing): Batch-set / GitOps flow for cloudflare-warp/windows
// =========================================================================
team := newTeam("team_" + t.Name())
// Add an ingested app to the team
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
}
packages := batchSet(team, softwareToInstall)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
title := getActiveTitleForTeam(team.ID)
require.Equal(t, "1.0", title.SoftwarePackage.Version)
require.Equal(t, "cloudflare-warp.msi", title.SoftwarePackage.Name)
// With only one version, fleet_maintained_versions should list it
require.Len(t, title.SoftwarePackage.FleetMaintainedVersions, 1)
require.Equal(t, "1.0", title.SoftwarePackage.FleetMaintainedVersions[0].Version)
// Now add a second version
resetFMAState(warpState, "2.0", []byte("def"))
packages = batchSet(team, softwareToInstall)
require.Len(t, packages, 2)
title = getActiveTitleForTeam(team.ID)
require.Equal(t, "2.0", title.SoftwarePackage.Version)
require.Equal(t, "cloudflare-warp.msi", title.SoftwarePackage.Name)
// fleet_maintained_versions should list both versions (newest first)
require.Len(t, title.SoftwarePackage.FleetMaintainedVersions, 2)
require.Equal(t, "2.0", title.SoftwarePackage.FleetMaintainedVersions[0].Version)
require.Equal(t, "1.0", title.SoftwarePackage.FleetMaintainedVersions[1].Version)
// We should have the previous version cached in the DB
installers, err := s.ds.GetSoftwareInstallers(ctx, team.ID)
s.Require().NoError(err)
s.Assert().Len(installers, 2)
// Now add a third version — should evict v1.0, keeping only v3.0 and v2.0
resetFMAState(warpState, "3.0", []byte("ghi"))
packages = batchSet(team, softwareToInstall)
require.Len(t, packages, 2)
title = getActiveTitleForTeam(team.ID)
require.Equal(t, "3.0", title.SoftwarePackage.Version)
// Only 2 versions should remain after eviction (max 2 cached)
require.Len(t, title.SoftwarePackage.FleetMaintainedVersions, 2)
require.Equal(t, "3.0", title.SoftwarePackage.FleetMaintainedVersions[0].Version)
require.Equal(t, "2.0", title.SoftwarePackage.FleetMaintainedVersions[1].Version)
// DB should also only have 2 installers
installers, err = s.ds.GetSoftwareInstallers(ctx, team.ID)
s.Require().NoError(err)
s.Assert().Len(installers, 2)
// ---- Test version rollback via fleet_maintained_app_version ----
// Pin to version "2.0" in the batch request (simulating GitOps yaml with version specified).
// The active installer should switch to the cached v2.0 installer.
packages = batchSet(team, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "2.0"},
})
require.Len(t, packages, 2)
// The active version shown in the title should now be "2.0"
title = getActiveTitleForTeam(team.ID)
require.Equal(t, "2.0", title.SoftwarePackage.Version)
// Both versions should still be listed
require.Len(t, title.SoftwarePackage.FleetMaintainedVersions, 2)
// Create a host in the team, trigger an install, and verify the downloaded bytes match v2.0
hostInTeam := newHostInTeam("windows", "orbit-host-rollback", team)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, title.ID), installSoftwareRequest{}, http.StatusAccepted)
// Get the queued installer ID from the pending install record
var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, `
SELECT software_installer_id FROM host_software_installs
WHERE host_id = ? AND software_installer_id IS NOT NULL AND install_script_exit_code IS NULL
ORDER BY created_at DESC LIMIT 1
`, hostInTeam.ID)
})
// Download the installer via orbit — should get the v2.0 bytes ("def")
r := s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerID,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusOK)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
r.Body.Close()
// v2.0 installer bytes were []byte("def")
require.Equal(t, []byte("def"), body, "downloaded installer should match v2.0 bytes")
downloadMu.Lock()
delete(downloadedSlugs, "cloudflare-warp/windows")
downloadMu.Unlock()
// ---- Test switching active version back to 3.0 ----
// Pin to version "3.0" — the active installer should switch to the cached v3.0 installer.
packages = batchSet(team, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "3.0"},
})
require.Len(t, packages, 2)
// The active version shown in the title should now be "3.0"
title = getActiveTitleForTeam(team.ID)
require.Equal(t, "3.0", title.SoftwarePackage.Version)
// Both versions should still be listed
require.Len(t, title.SoftwarePackage.FleetMaintainedVersions, 2)
// Trigger an install and verify the downloaded bytes match v3.0
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, title.ID), installSoftwareRequest{}, http.StatusAccepted)
// Get the queued installer ID from the pending install record
var installerIDv3 uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerIDv3, `
SELECT software_installer_id FROM host_software_installs
WHERE host_id = ? AND software_installer_id IS NOT NULL AND install_script_exit_code IS NULL
ORDER BY created_at DESC LIMIT 1
`, hostInTeam.ID)
})
// Download the installer via orbit — should get the v3.0 bytes ("ghi")
r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: installerIDv3,
OrbitNodeKey: *hostInTeam.OrbitNodeKey,
}, http.StatusOK)
body, err = io.ReadAll(r.Body)
require.NoError(t, err)
r.Body.Close()
// v3.0 installer bytes were []byte("ghi")
require.Equal(t, []byte("ghi"), body, "downloaded installer should match v3.0 bytes")
// We have version 3.0 cached, so we should not have downloaded the bytes
downloadMu.Lock()
warpDownloaded := downloadedSlugs["cloudflare-warp/windows"]
downloadMu.Unlock()
s.Assert().False(warpDownloaded)
// ---- Test rollback to an evicted version (v1.0) ----
// v1.0 was evicted when v3.0 was added (max 2 cached: v3.0 + v2.0).
// Per spec, pinning to an evicted version should fail with an error —
// Fleet should NOT re-download it.
downloadMu.Lock()
delete(downloadedSlugs, "cloudflare-warp/windows")
downloadMu.Unlock()
rawResp := s.Do("POST", "/api/latest/fleet/software/batch",
batchSetSoftwareInstallersRequest{
Software: []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "1.0"},
},
TeamName: team.Name,
},
http.StatusBadRequest, "team_name", team.Name, "team_id", fmt.Sprint(team.ID))
require.Contains(t, extractServerErrorText(rawResp.Body), "specified version is not available")
// Should NOT have hit the installer server — no re-download attempt
downloadMu.Lock()
warpDownloaded = downloadedSlugs["cloudflare-warp/windows"]
downloadMu.Unlock()
require.False(t, warpDownloaded, "should not attempt to re-download an evicted version")
// The active version should still be "3.0" (unchanged)
title = getActiveTitleForTeam(team.ID)
require.Equal(t, "3.0", title.SoftwarePackage.Version, "active version should remain 3.0 after failed rollback to evicted version")
// Attempt to add a custom package that will map to the same software title. Should fail
// (this tests the "custom installer vs existing FMA" direction).
installerContent := "installerbytes"
installerFile, err := fleet.NewTempFileReader(strings.NewReader(installerContent), t.TempDir)
require.NoError(t, err)
defer installerFile.Close()
user, err := s.ds.UserByEmail(context.Background(), "admin1@example.com")
require.NoError(t, err)
customPayload := &fleet.UploadSoftwareInstallerPayload{
TeamID: &team.ID,
Filename: "cloudflare-warp-installer.msi",
InstallerFile: installerFile,
Version: "99.0.0",
StorageID: computeSHA([]byte(installerContent)),
SelfService: true,
Title: "cloudflare warp", // windows installer, so Fleet does software title matching on this field
InstallScript: "install",
UninstallScript: "uninstall",
Source: "programs",
Platform: "windows",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
UserID: user.ID,
}
// We call the datastore method directly here because
// the service layer would attempt to extract metadata from the
// "installer", but that's just a fake file in this case so it'd fail.
// That logic isn't what we're trying to test here.
_, _, err = s.ds.MatchOrCreateSoftwareInstaller(ctx, customPayload)
assert.Error(t, err)
assert.Contains(t, err.Error(), fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, customPayload.Title, team.Name))
// =========================================================================
// Section 2: UI single-add flow
//
// The single-add endpoint (POST /fleet_maintained_apps) is used when a
// Fleet admin clicks "Add" in the UI, as opposed to the GitOps batch-set
// flow. This verifies that:
// a) The endpoint correctly installs v1.0 from scratch.
// b) A subsequent batch-set upgrade to v2.0 caches both versions.
// c) Rolling back to the UI-added v1.0 via batch-set works correctly.
// =========================================================================
t.Run("ui_single_add_flow", func(t *testing.T) {
// Reset warp state to v1.0 for this sub-test.
resetFMAState(warpState, "1.0", []byte("abc"))
// Create a fresh team so this sub-test is isolated from Section 1.
uiTeam := newTeam("team_ui_" + t.Name())
// Look up the cloudflare-warp/windows FMA ID that was inserted by SyncApps.
warpAppID := fmaAppID("cloudflare-warp/windows")
// Add the FMA via the single-add (UI) endpoint.
var addMAResp addFleetMaintainedAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{
AppID: warpAppID,
TeamID: &uiTeam.ID,
SelfService: true,
}, http.StatusOK, &addMAResp)
require.Nil(t, addMAResp.Err)
// Verify v1.0 was installed and is the only (active) version.
uiTitle := getActiveTitleForTeam(uiTeam.ID)
require.Equal(t, "1.0", uiTitle.SoftwarePackage.Version)
require.Len(t, uiTitle.SoftwarePackage.FleetMaintainedVersions, 1)
require.Equal(t, "1.0", uiTitle.SoftwarePackage.FleetMaintainedVersions[0].Version)
uiInstallers, err := s.ds.GetSoftwareInstallers(ctx, uiTeam.ID)
require.NoError(t, err)
require.Len(t, uiInstallers, 1)
// Now bump to v2.0 via the batch-set (GitOps) flow — this is how an
// upgrade would arrive after the initial UI-add.
resetFMAState(warpState, "2.0", []byte("def"))
pkgs := batchSet(uiTeam, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
})
require.Len(t, pkgs, 2, "both v1.0 (UI-added) and v2.0 should be cached")
uiTitle = getActiveTitleForTeam(uiTeam.ID)
require.Equal(t, "2.0", uiTitle.SoftwarePackage.Version)
require.Len(t, uiTitle.SoftwarePackage.FleetMaintainedVersions, 2)
uiInstallers, err = s.ds.GetSoftwareInstallers(ctx, uiTeam.ID)
require.NoError(t, err)
require.Len(t, uiInstallers, 2)
// Roll back to the original UI-added v1.0 via batch-set.
pkgs = batchSet(uiTeam, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "1.0"},
})
require.Len(t, pkgs, 2, "both versions should still be cached after rollback")
// Active version should now be v1.0 (the one originally added via the UI).
uiTitle = getActiveTitleForTeam(uiTeam.ID)
require.Equal(t, "1.0", uiTitle.SoftwarePackage.Version,
"active version should roll back to v1.0 (the version added via UI)")
require.Len(t, uiTitle.SoftwarePackage.FleetMaintainedVersions, 2,
"both versions should remain cached after rolling back")
})
// =========================================================================
// Section 3: FMA fails when a custom installer already exists for the same
// software title on the team (reverse of the conflict test in Section 1).
// =========================================================================
t.Run("fma_conflicts_with_existing_custom_installer", func(t *testing.T) {
// Reset warp state back to v1.0.
resetFMAState(warpState, "1.0", []byte("abc"))
conflictTeam := newTeam("team_conflict_" + t.Name())
user, err := s.ds.UserByEmail(ctx, "admin1@example.com")
require.NoError(t, err)
// Add a custom (non-FMA) installer for "Zoom Workplace (X64)" — the same
// unique_identifier that the zoom/windows FMA would use.
customInstallerFile, err := fleet.NewTempFileReader(strings.NewReader("fake-zoom-installer"), t.TempDir)
require.NoError(t, err)
defer customInstallerFile.Close()
// We call the datastore directly because the service layer tries to parse
// the installer binary (which is fake here).
_, _, err = s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
TeamID: &conflictTeam.ID,
Filename: "zoom-custom.msi",
InstallerFile: customInstallerFile,
Version: "1.0.0",
StorageID: computeSHA([]byte("fake-zoom-installer")),
SelfService: false,
Title: "Zoom Workplace (X64)", // matches zoom/windows FMA unique_identifier
InstallScript: "install",
UninstallScript: "uninstall",
Source: "programs",
Platform: "windows",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
UserID: user.ID,
})
require.NoError(t, err, "custom installer should be created without error")
// Now try to add the zoom/windows FMA via the single-add (UI) endpoint.
// It should fail because a custom installer already occupies the same
// software title on this team.
zoomAppID := fmaAppID("zoom/windows")
conflictResp := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps",
&addFleetMaintainedAppRequest{
AppID: zoomAppID,
TeamID: &conflictTeam.ID,
},
http.StatusConflict,
)
errMsg := extractServerErrorText(conflictResp.Body)
require.Contains(t, errMsg, "already has an installer available",
"error should mention the conflict with the existing custom installer")
// Confirm the FMA was NOT added — only the original custom installer exists.
conflictInstallers, err := s.ds.GetSoftwareInstallers(ctx, conflictTeam.ID)
require.NoError(t, err)
require.Len(t, conflictInstallers, 1, "FMA should not have been added when a custom installer already exists")
})
// =========================================================================
// Section 4: More than one FMA with cached versions on the same team.
//
// Both cloudflare-warp/windows and zoom/windows are added to the same team.
// Each goes through v1.0 → v2.0 → v3.0 (evicting v1.0 for each), then both
// are independently rolled back to v2.0. Verifies that eviction and rollback
// logic is per-FMA, not cross-FMA.
// =========================================================================
t.Run("multiple_fmas_cached_versions", func(t *testing.T) {
// Reset both FMA states to v1.0.
resetFMAState(warpState, "1.0", []byte("warp-v1"))
resetFMAState(zoomState, "1.0", []byte("zoom-v1"))
multiTeam := newTeam("team_multi_" + t.Name())
bothFMAs := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
{Slug: ptr.String("zoom/windows"), SelfService: true},
}
// ---- v1.0 for both FMAs ----
pkgs := batchSet(multiTeam, bothFMAs)
require.Len(t, pkgs, 2, "both FMAs at v1.0")
multiInstallers, err := s.ds.GetSoftwareInstallers(ctx, multiTeam.ID)
require.NoError(t, err)
require.Len(t, multiInstallers, 2)
// ---- v2.0 for both FMAs ----
resetFMAState(warpState, "2.0", []byte("warp-v2"))
resetFMAState(zoomState, "2.0", []byte("zoom-v2"))
pkgs = batchSet(multiTeam, bothFMAs)
require.Len(t, pkgs, 4, "two cached versions for each of the two FMAs")
multiInstallers, err = s.ds.GetSoftwareInstallers(ctx, multiTeam.ID)
require.NoError(t, err)
require.Len(t, multiInstallers, 4, "4 total installers: 2 per FMA")
// ---- v3.0 for both FMAs — should evict v1.0 for each ----
resetFMAState(warpState, "3.0", []byte("warp-v3"))
resetFMAState(zoomState, "3.0", []byte("zoom-v3"))
pkgs = batchSet(multiTeam, bothFMAs)
// After eviction each FMA keeps 2 versions (v3.0 + v2.0), so 4 total.
require.Len(t, pkgs, 4, "after eviction: 2 cached versions per FMA")
multiInstallers, err = s.ds.GetSoftwareInstallers(ctx, multiTeam.ID)
require.NoError(t, err)
require.Len(t, multiInstallers, 4, "4 total installers after eviction of v1.0 for each FMA")
// ---- Roll back cloudflare-warp to v2.0, leave zoom at v3.0 ----
pkgs = batchSet(multiTeam, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "2.0"},
{Slug: ptr.String("zoom/windows"), SelfService: true}, // no rollback — stays at latest
})
require.Len(t, pkgs, 4)
// Collect active versions by title name for easy assertion.
var multiTitlesResp listSoftwareTitlesResponse
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &multiTitlesResp,
"available_for_install", "true",
"team_id", fmt.Sprintf("%d", multiTeam.ID),
)
require.Equal(t, 2, multiTitlesResp.Count)
activeVersions := map[string]string{}
for _, tl := range multiTitlesResp.SoftwareTitles {
activeVersions[tl.Name] = tl.SoftwarePackage.Version
}
require.Equal(t, "2.0", activeVersions["Cloudflare WARP"],
"warp should be rolled back to v2.0")
require.Equal(t, "3.0", activeVersions["Zoom Workplace (X64)"],
"zoom should remain at v3.0 (not rolled back)")
// Zoom rollback to v2.0 should also succeed independently.
pkgs = batchSet(multiTeam, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "2.0"},
{Slug: ptr.String("zoom/windows"), SelfService: true, RollbackVersion: "2.0"},
})
require.Len(t, pkgs, 4)
multiTitlesResp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &multiTitlesResp,
"available_for_install", "true",
"team_id", fmt.Sprintf("%d", multiTeam.ID),
)
require.Equal(t, 2, multiTitlesResp.Count)
activeVersions = map[string]string{}
for _, tl := range multiTitlesResp.SoftwareTitles {
activeVersions[tl.Name] = tl.SoftwarePackage.Version
}
require.Equal(t, "2.0", activeVersions["Cloudflare WARP"], "warp should still be at v2.0")
require.Equal(t, "2.0", activeVersions["Zoom Workplace (X64)"], "zoom should now be rolled back to v2.0")
})
// =========================================================================
// Section 5: Team isolation — cached versions on one team must not affect
// another team that holds the same FMA.
//
// Team A advances cloudflare-warp through v1.0 → v2.0 → v3.0 (evicts v1.0).
// Team B advances only to v2.0 and should still have v1.0 cached afterward.
// Rolling back Team A to v2.0 must not disturb Team B.
// =========================================================================
t.Run("team_isolation", func(t *testing.T) {
// Reset warp state to v1.0.
resetFMAState(warpState, "1.0", []byte("iso-warp-v1"))
teamA := newTeam("team_iso_a_" + t.Name())
teamB := newTeam("team_iso_b_" + t.Name())
warpPayload := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
}
// ---- Add v1.0 to both teams ----
batchSet(teamA, warpPayload)
batchSet(teamB, warpPayload)
// Both teams should have exactly 1 installer (v1.0).
instA, err := s.ds.GetSoftwareInstallers(ctx, teamA.ID)
require.NoError(t, err)
require.Len(t, instA, 1)
instB, err := s.ds.GetSoftwareInstallers(ctx, teamB.ID)
require.NoError(t, err)
require.Len(t, instB, 1)
// ---- Advance both teams to v2.0 ----
resetFMAState(warpState, "2.0", []byte("iso-warp-v2"))
batchSet(teamA, warpPayload)
batchSet(teamB, warpPayload)
// Both teams should have 2 cached installers (v1.0 + v2.0).
instA, err = s.ds.GetSoftwareInstallers(ctx, teamA.ID)
require.NoError(t, err)
require.Len(t, instA, 2)
instB, err = s.ds.GetSoftwareInstallers(ctx, teamB.ID)
require.NoError(t, err)
require.Len(t, instB, 2)
// ---- Advance Team A to v3.0 (evicts v1.0 on Team A only) ----
resetFMAState(warpState, "3.0", []byte("iso-warp-v3"))
batchSet(teamA, warpPayload)
// Team A: v1.0 evicted → only v2.0 + v3.0 remain.
instA, err = s.ds.GetSoftwareInstallers(ctx, teamA.ID)
require.NoError(t, err)
require.Len(t, instA, 2, "Team A should still have 2 installers after v1.0 eviction")
titleA := getActiveTitleForTeam(teamA.ID)
require.Equal(t, "3.0", titleA.SoftwarePackage.Version, "Team A active version should be v3.0")
require.Len(t, titleA.SoftwarePackage.FleetMaintainedVersions, 2)
require.Equal(t, "3.0", titleA.SoftwarePackage.FleetMaintainedVersions[0].Version)
require.Equal(t, "2.0", titleA.SoftwarePackage.FleetMaintainedVersions[1].Version)
// Team B: must be completely unaffected — still has v1.0 + v2.0.
instB, err = s.ds.GetSoftwareInstallers(ctx, teamB.ID)
require.NoError(t, err)
require.Len(t, instB, 2, "Team B should still have 2 installers; Team A's eviction must not affect Team B")
titleB := getActiveTitleForTeam(teamB.ID)
require.Equal(t, "2.0", titleB.SoftwarePackage.Version, "Team B active version should remain v2.0")
require.Len(t, titleB.SoftwarePackage.FleetMaintainedVersions, 2)
require.Equal(t, "2.0", titleB.SoftwarePackage.FleetMaintainedVersions[0].Version)
require.Equal(t, "1.0", titleB.SoftwarePackage.FleetMaintainedVersions[1].Version,
"Team B should still have v1.0 cached even though Team A evicted it")
// ---- Roll back Team A to v2.0 — Team B must not be affected ----
batchSet(teamA, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "2.0"},
})
titleA = getActiveTitleForTeam(teamA.ID)
require.Equal(t, "2.0", titleA.SoftwarePackage.Version, "Team A should be rolled back to v2.0")
// Team B still unaffected.
titleB = getActiveTitleForTeam(teamB.ID)
require.Equal(t, "2.0", titleB.SoftwarePackage.Version,
"Team B active version should remain v2.0 after Team A rollback")
require.Len(t, titleB.SoftwarePackage.FleetMaintainedVersions, 2,
"Team B cached versions should be unchanged")
// Verify Team B can still roll back to v1.0 (its eviction pool is independent).
batchSet(teamB, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "1.0"},
})
titleB = getActiveTitleForTeam(teamB.ID)
require.Equal(t, "1.0", titleB.SoftwarePackage.Version,
"Team B should successfully roll back to v1.0, which is still in its cache")
})
// =========================================================================
// Section 6: Deleting an FMA software title removes all cached versions,
// not just the active one.
//
// An FMA is advanced through v1.0 → v2.0 → v3.0 so that two versions are
// cached (v3.0 active, v2.0 inactive, v1.0 evicted). After deleting the
// software title the DB should contain zero software_installers rows for
// that title+team.
// =========================================================================
t.Run("delete_fma_cleans_all_cached_versions", func(t *testing.T) {
resetFMAState(warpState, "1.0", []byte("del-warp-v1"))
delTeam := newTeam("team_del_" + t.Name())
warpPayload := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
}
batchSet(delTeam, warpPayload)
// ---- v2.0 ----
resetFMAState(warpState, "2.0", []byte("del-warp-v2"))
batchSet(delTeam, warpPayload)
// Confirm we have exactly 2 cached installers (v1.0 + v2.0).
delInstallers, err := s.ds.GetSoftwareInstallers(ctx, delTeam.ID)
require.NoError(t, err)
require.Len(t, delInstallers, 2, "should have 2 cached FMA versions before delete")
// Retrieve the title ID so we can call the delete endpoint.
delTitle := getActiveTitleForTeam(delTeam.ID)
require.Equal(t, "2.0", delTitle.SoftwarePackage.Version)
// Delete via the API endpoint — this is the path exercised by the UI.
s.Do("DELETE",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", delTitle.ID),
nil, http.StatusNoContent,
"team_id", fmt.Sprint(delTeam.ID),
)
// After deletion, zero software_installer rows should remain for this
// title+team — both the active (v2.0) and the inactive cached (v1.0)
// version must be gone.
delInstallers, err = s.ds.GetSoftwareInstallers(ctx, delTeam.ID)
require.NoError(t, err)
require.Empty(t, delInstallers,
"all cached FMA versions (active + inactive) should be deleted, not just the active one")
// The software title should no longer appear in the available-for-install
// list for the team.
var titlesResp listSoftwareTitlesResponse
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &titlesResp,
"available_for_install", "true",
"team_id", fmt.Sprintf("%d", delTeam.ID),
)
require.Zero(t, titlesResp.Count,
"software title should no longer be available for install after deletion")
})
// =========================================================================
// Section 7: Status summary resets to all-zeros when the active version
// changes.
//
// 1. Add warp at v1.0, then advance to v2.0 (v2.0 active, v1.0 cached).
// 2. Install v2.0 on a host and record a success → summary shows Installed: 1.
// 3. Roll back to v1.0 via batch-set (v1.0 becomes the active installer).
// 4. Assert the active installer's (v1.0) status summary is all zeros —
// the install history from v2.0 must not bleed over.
// =========================================================================
t.Run("status_summary_resets_on_version_switch", func(t *testing.T) {
// Reset warp state to v1.0 for this sub-test.
resetFMAState(warpState, "1.0", []byte("status-warp-v1"))
statusTeam := newTeam("team_status_" + t.Name())
warpPayload := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true},
}
// ---- Step 1a: Add v1.0 ----
batchSet(statusTeam, warpPayload)
// ---- Step 1b: Advance to v2.0 ----
resetFMAState(warpState, "2.0", []byte("status-warp-v2"))
batchSet(statusTeam, warpPayload)
statusTitle := getActiveTitleForTeam(statusTeam.ID)
require.Equal(t, "2.0", statusTitle.SoftwarePackage.Version)
titleID := statusTitle.ID
// ---- Step 2: Install v2.0 on a host and record success ----
statusHost := newHostInTeam("windows", "orbit-host-status-"+t.Name(), statusTeam)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", statusHost.ID, titleID),
installSoftwareRequest{}, http.StatusAccepted)
// Retrieve the execution UUID for the pending install.
installUUID := getLatestSoftwareInstallExecID(t, s.ds, statusHost.ID)
// Report a successful install via orbit.
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *statusHost.OrbitNodeKey, installUUID)), http.StatusNoContent)
// Verify: v2.0 installer summary shows Installed: 1.
var titleRespV2 getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil,
http.StatusOK, &titleRespV2, "team_id", fmt.Sprint(statusTeam.ID))
require.NotNil(t, titleRespV2.SoftwareTitle.SoftwarePackage.Status)
require.Equal(t, uint(1), titleRespV2.SoftwareTitle.SoftwarePackage.Status.Installed,
"v2.0 installer should show Installed: 1 after a successful install")
// ---- Step 3: Roll back to v1.0 ----
batchSet(statusTeam, []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: true, RollbackVersion: "1.0"},
})
statusTitle = getActiveTitleForTeam(statusTeam.ID)
require.Equal(t, "1.0", statusTitle.SoftwarePackage.Version,
"active version should be v1.0 after rollback")
// ---- Step 4: Assert the active (v1.0) installer summary is all zeros ----
// v1.0's software_installers row was never the target of any install
// request, so its summary must be completely empty. The title ID is
// stable across version switches, so we can query it directly.
var titleRespV1 getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil,
http.StatusOK, &titleRespV1, "team_id", fmt.Sprint(statusTeam.ID))
require.NotNil(t, titleRespV1.SoftwareTitle.SoftwarePackage.Status)
require.Equal(t, fleet.SoftwareInstallerStatusSummary{}, *titleRespV1.SoftwareTitle.SoftwarePackage.Status,
"v1.0 installer status summary must be all zeros after switching from v2.0 — "+
"install history from v2.0 must not carry over to the rolled-back version")
})
// =========================================================================
// Section 8: Editing an FMA that has multiple cached versions must succeed.
//
// Regression test for the bug where SoftwareTitleByID counted ALL
// software_installers rows for a title+team (active + inactive cached
// versions) instead of only the active one. That made
// SoftwareInstallersCount > 1, which tripped the `!= 1` guard in
// UpdateSoftwareInstaller and returned a false "no installers defined"
// 400 error whenever an admin tried to edit an FMA that had any cached
// inactive version.
//
// The fix is `AND si.is_active = TRUE` in the LEFT JOIN inside
// SoftwareTitleByID so the count stays at exactly 1.
// =========================================================================
t.Run("edit_fma_with_cached_versions", func(t *testing.T) {
resetFMAState(warpState, "1.0", []byte("edit-warp-v1"))
editTeam := newTeam("team_edit_" + t.Name())
warpPayload := []*fleet.SoftwareInstallerPayload{
{Slug: ptr.String("cloudflare-warp/windows"), SelfService: false},
}
// ---- Add v1.0 ----
batchSet(editTeam, warpPayload)
// ---- Advance to v2.0 so there are now 2 cached rows (v1.0 inactive, v2.0 active) ----
resetFMAState(warpState, "2.0", []byte("edit-warp-v2"))
batchSet(editTeam, warpPayload)
editTitle := getActiveTitleForTeam(editTeam.ID)
require.Equal(t, "2.0", editTitle.SoftwarePackage.Version)
// Confirm self_service starts as false before the edit.
var titleRespBefore getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", editTitle.ID), nil,
http.StatusOK, &titleRespBefore, "team_id", fmt.Sprint(editTeam.ID))
require.False(t, titleRespBefore.SoftwareTitle.SoftwarePackage.SelfService,
"self_service should be false before the edit")
// Sanity-check: both cached versions are present in the DB.
editInstallers, err := s.ds.GetSoftwareInstallers(ctx, editTeam.ID)
require.NoError(t, err)
require.Len(t, editInstallers, 2, "should have 2 cached FMA versions before attempting edit")
// ---- Attempt a metadata-only edit (toggle self_service) ----
// Without the fix, SoftwareTitleByID counts both installer rows and returns
// SoftwareInstallersCount = 2, which makes UpdateSoftwareInstaller return a
// 400 "no installers defined" error. With the fix the count is 1 and the
// edit succeeds.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
TitleID: editTitle.ID,
TeamID: &editTeam.ID,
SelfService: ptr.Bool(true),
}, http.StatusOK, "")
// Confirm the edit actually took effect.
var titleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", editTitle.ID), nil,
http.StatusOK, &titleResp, "team_id", fmt.Sprint(editTeam.ID))
require.True(t, titleResp.SoftwareTitle.SoftwarePackage.SelfService,
"self_service should be true after the edit")
})
}

View file

@ -200,6 +200,15 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
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)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions")
}
meta.FleetMaintainedVersions = fmaVersions
}
}
// add VPP app data if needed

View file

@ -109,6 +109,7 @@ github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsExcludeAny []
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec InstallDuringSetup optjson.Bool
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Icon fleet.TeamSpecSoftwareAsset
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Slug *string
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Version string
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec ReferencedYamlPath string
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec SHA256 string
github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Categories []string
@ -118,6 +119,7 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MaintainedAppSpec] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MaintainedAppSpec] Value []fleet.MaintainedAppSpec
github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Slug string
github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Version string
github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec SelfService bool
github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec PreInstallQuery fleet.TeamSpecSoftwareAsset
github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec InstallScript fleet.TeamSpecSoftwareAsset