UI – Implement changes for package uninstall scripts in the add software modal (#21828)

## Addresses #21564 – see issue for task list
![Screenshot 2024-09-04 at 5 45
12 PM](https://github.com/user-attachments/assets/546401dd-b56e-4c39-baba-456dc844ee0f)
![Screenshot 2024-09-04 at 5 42
57 PM](https://github.com/user-attachments/assets/810ca450-0ddd-4258-96a5-bddb300ae19d)
![Screenshot 2024-09-04 at 5 45
02 PM](https://github.com/user-attachments/assets/32a19ce6-52c3-4772-ba53-00e50145bc85)
![Screenshot 2024-09-04 at 5 43
23 PM](https://github.com/user-attachments/assets/925843fb-6290-489b-a639-de1cbfba83fa)

- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2024-09-05 11:11:14 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 25d08d1051
commit 0cfbdc6f58
13 changed files with 364 additions and 88 deletions
@@ -13,6 +13,7 @@ export interface IRevealButtonProps {
autofocus?: boolean;
disabled?: boolean;
tooltipContent?: React.ReactNode;
disabledTooltipContent?: React.ReactNode;
onClick?:
| ((value?: any) => void)
| ((evt: React.MouseEvent<HTMLButtonElement>) => void);
@@ -29,6 +30,7 @@ const RevealButton = ({
autofocus,
disabled,
tooltipContent,
disabledTooltipContent,
onClick,
}: IRevealButtonProps): JSX.Element => {
const classNames = classnames(baseClass, className);
@@ -36,11 +38,12 @@ const RevealButton = ({
const buttonContent = () => {
const text = isShowing ? hideText : showText;
const buttonText = tooltipContent ? (
<TooltipWrapper tipContent={tooltipContent}>{text}</TooltipWrapper>
) : (
text
);
const buttonText =
tooltipContent && !disabled ? (
<TooltipWrapper tipContent={tooltipContent}>{text}</TooltipWrapper>
) : (
text
);
return (
<>
@@ -61,7 +64,7 @@ const RevealButton = ({
);
};
return (
const button = (
<Button
variant="text-icon"
className={classNames}
@@ -72,6 +75,22 @@ const RevealButton = ({
{buttonContent()}
</Button>
);
if (disabled && disabledTooltipContent) {
// wrap the tooltip around the Button so it works while disabled
return (
<TooltipWrapper
tipContent={disabledTooltipContent}
showArrow
underline={false}
position="right"
tipOffset={12}
>
{button}
</TooltipWrapper>
);
}
return button;
};
export default RevealButton;
+22
View File
@@ -0,0 +1,22 @@
const unixPackageTypes = ["pkg", "deb"] as const;
const windowsPackageTypes = ["msi", "exe"] as const;
export const packageTypes = [
...unixPackageTypes,
...windowsPackageTypes,
] as const;
export type WindowsPackageType = typeof windowsPackageTypes[number];
export type UnixPackageType = typeof unixPackageTypes[number];
export type PackageType = WindowsPackageType | UnixPackageType;
export const isWindowsPackageType = (s: any): s is WindowsPackageType => {
return windowsPackageTypes.includes(s);
};
export const isUnixPackageType = (s: any): s is UnixPackageType => {
return unixPackageTypes.includes(s);
};
export const isPackageType = (s: any): s is PackageType => {
return packageTypes.includes(s);
};
@@ -1,33 +1,219 @@
import React, { useState } from "react";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
isPackageType,
isWindowsPackageType,
PackageType,
WindowsPackageType,
} from "interfaces/package_type";
import Editor from "components/Editor";
import CustomLink from "components/CustomLink";
import FleetAce from "components/FleetAce";
import RevealButton from "components/buttons/RevealButton";
import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm";
const unixInstallHelpText = (
<>
Use the $INSTALLER_PATH to point to the installer. Shell scripts are
supported.{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/install-scripts`}
text="Learn more about install scripts"
newTab
/>
</>
);
const getWindowsInstallHelpText = (pkgType: WindowsPackageType) => (
<>
Use the $INSTALLER_PATH to point to the installer. PowerShell scripts are
supported.{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/${
pkgType === "exe" ? "exe-" : ""
}install-scripts`}
text="Learn more about install scripts"
newTab
/>
</>
);
const unixPostInstallHelpText = "Shell scripts are supported.";
const windowsPostInstallHelpText = "PowerShell scripts are supported.";
const PKG_TYPE_TO_ID_TEXT = {
pkg: "package IDs",
deb: "package name",
msi: "product code",
exe: "software name",
} as const;
const getUninstallHelpText = (type: PackageType) => {
return (
<>
$PACKAGE_ID will be populated with the {PKG_TYPE_TO_ID_TEXT[type]} from
the .{type} file after the software is added.{" "}
{isWindowsPackageType(type) && "Power"}
Shell scripts are supported.{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/uninstall-scripts`}
text="Learn more about uninstall scripts"
newTab
/>
</>
);
};
const PACKAGE_TYPES_TO_HELP_TEXT: Record<
PackageType,
Record<string, Record<string, React.ReactNode>>
> = {
pkg: {
install: {
helpText: unixInstallHelpText,
},
postInstall: {
helpText: unixPostInstallHelpText,
},
uninstall: {
helpText: getUninstallHelpText("pkg"),
},
},
deb: {
install: {
helpText: unixInstallHelpText,
},
postInstall: {
helpText: unixPostInstallHelpText,
},
uninstall: {
helpText: getUninstallHelpText("deb"),
},
},
msi: {
install: {
helpText: getWindowsInstallHelpText("msi"),
},
postInstall: {
helpText: windowsPostInstallHelpText,
},
uninstall: {
helpText: getUninstallHelpText("msi"),
},
},
exe: {
install: {
helpText: getWindowsInstallHelpText("exe"),
},
postInstall: {
helpText: windowsPostInstallHelpText,
},
uninstall: {
helpText: getUninstallHelpText("exe"),
},
},
} as const;
const baseClass = "add-package-advanced-options";
interface IAddPackageAdvancedOptionsProps {
errors: { preInstallQuery?: string; postInstallScript?: string };
selectedPackage: IAddPackageFormData["software"];
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
uninstallScript?: string;
onChangePreInstallQuery: (value?: string) => void;
onChangeInstallScript: (value: string) => void;
onChangePostInstallScript: (value?: string) => void;
onChangeUninstallScript: (value?: string) => void;
}
const AddPackageAdvancedOptions = ({
errors,
selectedPackage,
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
onChangePreInstallQuery,
onChangeInstallScript,
onChangePostInstallScript,
onChangeUninstallScript,
}: IAddPackageAdvancedOptionsProps) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const renderAdvancedOptions = () => {
const name = selectedPackage?.name || "";
const ext = name.split(".").pop() as PackageType;
if (!isPackageType(ext)) {
// this should never happen
return null;
}
return (
<div className={`${baseClass}__input-fields`}>
<FleetAce
className="form-field"
focus
error={errors.preInstallQuery}
value={preInstallQuery}
placeholder="SELECT * FROM osquery_info WHERE start_time > 1"
label="Pre-install query"
name="preInstallQuery"
maxLines={10}
onChange={onChangePreInstallQuery}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
<Editor
wrapEnabled
maxLines={10}
name="install-script"
onChange={onChangeInstallScript}
value={installScript}
helpText={PACKAGE_TYPES_TO_HELP_TEXT[ext].install.helpText}
label="Install script"
isFormField
/>
<Editor
label="Post-install script"
focus
error={errors.postInstallScript}
wrapEnabled
name="post-install-script-editor"
maxLines={10}
onChange={onChangePostInstallScript}
value={postInstallScript}
helpText={PACKAGE_TYPES_TO_HELP_TEXT[ext].postInstall.helpText}
isFormField
/>
<Editor
label="Uninstall script"
focus
wrapEnabled
name="uninstall-script-editor"
maxLines={20}
onChange={onChangeUninstallScript}
value={uninstallScript}
helpText={PACKAGE_TYPES_TO_HELP_TEXT[ext].uninstall.helpText}
isFormField
/>
</div>
);
};
return (
<div className={baseClass}>
<RevealButton
@@ -37,63 +223,14 @@ const AddPackageAdvancedOptions = ({
hideText="Advanced options"
caretPosition="after"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
disabled={!selectedPackage}
disabledTooltipContent={
selectedPackage
? "Choose a file to modify advanced options."
: undefined
}
/>
{showAdvancedOptions && (
<div className={`${baseClass}__input-fields`}>
<FleetAce
className="form-field"
focus
error={errors.preInstallQuery}
value={preInstallQuery}
placeholder="SELECT * FROM osquery_info WHERE start_time > 1"
label="Pre-install query"
name="preInstallQuery"
maxLines={10}
onChange={onChangePreInstallQuery}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
<Editor
wrapEnabled
maxLines={10}
name="install-script"
onChange={onChangeInstallScript}
value={installScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
label="Install script"
labelTooltip={
<>
Fleet will run this script on hosts to install software. Use the
<br />
$INSTALLER_PATH variable to point to the installer.
</>
}
isFormField
/>
<Editor
label="Post-install script"
labelTooltip="Fleet will run this script after install."
focus
error={errors.postInstallScript}
wrapEnabled
name="post-install-script-editor"
maxLines={10}
onChange={onChangePostInstallScript}
value={postInstallScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
isFormField
/>
</div>
)}
{showAdvancedOptions && !!selectedPackage && renderAdvancedOptions()}
</div>
);
};
@@ -2,7 +2,8 @@ import React, { useContext, useState } from "react";
import { NotificationContext } from "context/notification";
import { getFileDetails } from "utilities/file/fileUtils";
import getInstallScript from "utilities/software_install_scripts";
import getDefaultInstallScript from "utilities/software_install_scripts";
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
@@ -30,9 +31,10 @@ const UploadingSoftware = () => {
export interface IAddPackageFormData {
software: File | null;
installScript: string;
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
uninstallScript?: string;
selfService: boolean;
}
@@ -59,9 +61,10 @@ const AddPackageForm = ({
const [formData, setFormData] = useState<IAddPackageFormData>({
software: null,
installScript: "",
preInstallQuery: undefined,
installScript: "",
postInstallScript: undefined,
uninstallScript: undefined,
selfService: false,
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
@@ -69,13 +72,21 @@ const AddPackageForm = ({
software: { isValid: false },
});
const onFileUpload = (files: FileList | null) => {
const onFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
let installScript: string;
let defaultInstallScript: string;
try {
installScript = getInstallScript(file.name);
defaultInstallScript = getDefaultInstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
}
let defaultUninstallScript: string;
try {
defaultUninstallScript = getDefaultUninstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
@@ -84,7 +95,8 @@ const AddPackageForm = ({
const newData = {
...formData,
software: file,
installScript,
installScript: defaultInstallScript,
uninstallScript: defaultUninstallScript,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
@@ -112,6 +124,12 @@ const AddPackageForm = ({
setFormValidation(generateFormValidation(newData));
};
const onChangeUninstallScript = (value?: string) => {
const newData = { ...formData, uninstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
setFormData(newData);
@@ -130,7 +148,7 @@ const AddPackageForm = ({
graphicName={"file-pkg"}
accept=".pkg,.msi,.exe,.deb"
message=".pkg, .msi, .exe, or .deb"
onFileUpload={onFileUpload}
onFileUpload={onFileSelect}
buttonMessage="Choose file"
buttonType="link"
className={`${baseClass}__file-uploader`}
@@ -156,16 +174,19 @@ const AddPackageForm = ({
</TooltipWrapper>
</Checkbox>
<AddPackageAdvancedOptions
selectedPackage={formData.software}
errors={{
preInstallQuery: formValidation.preInstallQuery?.message,
postInstallScript: formValidation.postInstallScript?.message,
}}
preInstallQuery={formData.preInstallQuery}
installScript={formData.installScript}
postInstallScript={formData.postInstallScript}
uninstallScript={formData.uninstallScript}
onChangePreInstallQuery={onChangePreInstallQuery}
onChangeInstallScript={onChangeInstallScript}
onChangePostInstallScript={onChangePostInstallScript}
installScript={formData.installScript}
onChangeUninstallScript={onChangeUninstallScript}
/>
<div className="modal-cta-wrap">
<Button type="submit" variant="brand" disabled={isSubmitDisabled}>
@@ -1,5 +1,3 @@
import validator from "validator";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
@@ -7,7 +5,7 @@ import { IAddPackageFormData, IFormValidation } from "./AddPackageForm";
type IAddPackageFormValidatorKey = Exclude<
keyof IAddPackageFormData,
"installScript"
"installScript" | "uninstallScript"
>;
type IMessageFunc = (formData: IAddPackageFormData) => string;
+3
View File
@@ -132,6 +132,9 @@ $max-width: 2560px;
font-size: $xx-small;
font-weight: $regular;
@include grey-text;
.custom-link {
font-size: inherit;
}
}
@mixin link {
@@ -11,7 +11,7 @@ import installDeb from "../../pkg/file/scripts/install_deb.sh";
* getInstallScript returns a string with a script to install the
* provided software.
* */
const getInstallScript = (fileName: string): string => {
const getDefaultInstallScript = (fileName: string): string => {
const extension = fileName.split(".").pop();
switch (extension) {
case "pkg":
@@ -27,4 +27,4 @@ const getInstallScript = (fileName: string): string => {
}
};
export default getInstallScript;
export default getDefaultInstallScript;
@@ -0,0 +1,30 @@
// @ts-ignore
import uninstallPkg from "../../pkg/file/scripts/uninstall_pkg.sh";
// @ts-ignore
import uninstallMsi from "../../pkg/file/scripts/uninstall_msi.ps1";
// @ts-ignore
import uninstallExe from "../../pkg/file/scripts/uninstall_exe.ps1";
// @ts-ignore
import uninstallDeb from "../../pkg/file/scripts/uninstall_deb.sh";
/*
* getUninstallScript returns a string with a script to uninstall the
* provided software.
* */
const getDefaultUninstallScript = (fileName: string): string => {
const extension = fileName.split(".").pop();
switch (extension) {
case "pkg":
return uninstallPkg;
case "msi":
return uninstallMsi;
case "deb":
return uninstallDeb;
case "exe":
return uninstallExe;
default:
throw new Error(`unsupported file extension: ${extension}`);
}
};
export default getDefaultUninstallScript;
+16 -12
View File
@@ -1,16 +1,20 @@
# Learn more about .exe install scripts: http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# extract the name of the executable to use as the sub-directory name
$exeName = [System.IO.Path]::GetFileName($exeFilePath)
$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath)
$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir
# check if the directory does not exist, and create it if necessary
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath
# Add argument to install silently
# Argument to make install silent depends on installer,
# each installer might use different argument (usually it's "/S" or "/s")
$processOptions = @{
FilePath = "$exeFilePath"
ArgumentList = "/S"
PassThru = $true
Wait = $true
}
# Start process and track exit code
$process = Start-Process @processOptions
$exitCode = $process.ExitCode
# copy the .exe file to the new sub-directory
$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName
Copy-Item -Path $exeFilePath -Destination $destinationExePath
# Prints the exit code
Write-Host "Install exit code: $exitCode"
+4
View File
@@ -0,0 +1,4 @@
$package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
apt remove $package_name
+17
View File
@@ -0,0 +1,17 @@
# Fleet extracts name from installer (EXE) and saves it to package ID variable
$softwareName = $PACKAGE_ID
# Get the list of subkeys under the Uninstall registry path
$uninstallKeys = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | ForEach-Object { Get-ItemProperty $_.PSPath }
# Loop through each registry key to find the one containing "$softwareName" in DisplayName and run uninstall command from UninstallString
foreach ($key in $uninstallKeys) {
if ($key.DisplayName -like "*$softwareName*") {
# Get the uninstall command
$uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
# Run the uninstall command with arguments using the call operator &
& $uninstallCommand
break # Exit the loop once the software is found and uninstalled
}
}
+4
View File
@@ -0,0 +1,4 @@
$product_code = $PACKAGE_ID
# Fleet uninstalls app using product code that's extracted on upload
msiexec /x $product_code
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Fleet extracts and saves package IDs
pkg_ids=$PACKAGE_ID
# Get all files associated with package and remove them
for pkg_id in "${pkg_ids[@]}"
do
pkgutil --files $pkg_id | tr '\n' '\0' | xargs -n 1 -0 rm -d
done
# Loop through each pkg_id and remove receipts
for pkg_id in "${pkg_ids[@]}"
do
pkgutil --forget $pkg_id
done