mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
a58678ea28
commit
ac4ec2ff27
45 changed files with 2129 additions and 248 deletions
1
changes/31919-rollback
Normal file
1
changes/31919-rollback
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added ability to roll back to previously added versions of Fleet-maintained apps.
|
||||
1
changes/31919-surface-latest-fma-version
Normal file
1
changes/31919-surface-latest-fma-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Fleet UI: Surface FMA version used and whether it's out of date
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// Fleet‑maintained 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ const EditSoftwareModal = ({
|
|||
labels={labels || []}
|
||||
className={formClassNames}
|
||||
isEditingSoftware
|
||||
isFleetMaintainedApp={isFleetMaintainedApp}
|
||||
onCancel={onExit}
|
||||
onSubmit={onClickSavePackage}
|
||||
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
|
||||
|
|
|
|||
|
|
@ -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 > Edit</strong>{" "}
|
||||
software.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{version} {isLatestFmaVersion ? "(latest)" : ""}
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
if (installerType === "app-store") {
|
||||
versionInfo = (
|
||||
<TooltipWrapper tipContent={<span>Updated every hour.</span>}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.package-version-selector {
|
||||
width: 250px;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PackageVersionSelector";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1
pkg/spec/testdata/team_config.yml
vendored
1
pkg/spec/testdata/team_config.yml
vendored
|
|
@ -59,6 +59,7 @@ software:
|
|||
- a
|
||||
fleet_maintained_apps:
|
||||
- slug: slack/darwin
|
||||
version: "4.47.65"
|
||||
self_service: true
|
||||
categories:
|
||||
- Productivity
|
||||
|
|
|
|||
1
pkg/spec/testdata/team_config_no_paths.yml
vendored
1
pkg/spec/testdata/team_config_no_paths.yml
vendored
|
|
@ -163,6 +163,7 @@ software:
|
|||
- a
|
||||
fleet_maintained_apps:
|
||||
- slug: slack/darwin
|
||||
version: "4.47.65"
|
||||
self_service: true
|
||||
categories:
|
||||
- Productivity
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ software:
|
|||
setup_experience: true
|
||||
fleet_maintained_apps:
|
||||
- slug: slack/darwin
|
||||
version: "4.47.65"
|
||||
self_service: true
|
||||
categories:
|
||||
- Productivity
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:"-"`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1276,6 +1276,7 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
|
|||
|
||||
if si.Slug != nil {
|
||||
softwarePayloads[i].Slug = si.Slug
|
||||
softwarePayloads[i].RollbackVersion = si.Version
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue