Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** This component is intentionally separate from SoftwareInstallDetailsModal
* because it handles script-only package installs (e.g. sh_packages or ps1_packages)
* because it handles script-only package installs (e.g. sh_packages, ps1_packages, or py_packages)
*
* Key differences from SoftwareInstallDetailsModal:
* - Uses Script/Run/Rerun language in UI instead of Install/Retry.
Expand Down
2 changes: 1 addition & 1 deletion frontend/interfaces/package_type.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fleetMaintainedPackageTypes = ["dmg", "zip"] as const;
const unixPackageTypes = ["pkg", "deb", "rpm", "dmg", "zip", "tar.gz"] as const;
const windowsPackageTypes = ["msi", "exe", "zip"] as const;
const scriptOnlyPackageTypes = ["sh", "ps1"] as const;
const scriptOnlyPackageTypes = ["sh", "ps1", "py"] as const;
const iosIpadosPackageTypes = ["ipa"] as const;
export const packageTypes = [
...unixPackageTypes,
Expand Down
4 changes: 2 additions & 2 deletions frontend/interfaces/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type SetupStepStatus = typeof SETUP_STEP_STATUSES[number];
/** These type extends onto API returned software steps */
export const SETUP_STEP_TYPES = [
"software_install", // API key: software
"software_script_run", // API key: software, detected via source === "sh_packages" || "ps1_packages"
"software_script_run", // API key: software, detected via a script package source (see SCRIPT_PACKAGE_SOURCES)
"script_run", // API key: scripts
];

Expand All @@ -24,7 +24,7 @@ export interface ISetupStep {
status: SetupStepStatus;
type: SetupStepType;
error?: string | null;
source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "apps")
source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "py_packages", "apps")
display_name?: string | null;
icon_url?: string | null;
}
8 changes: 7 additions & 1 deletion frontend/interfaces/software.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export const SOURCE_TYPE_CONVERSION = {
vscode_extensions: "IDE extension", // vscode_extensions can include any vscode-based editor (e.g., Cursor, Trae, Windsurf), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
sh_packages: "Script-only package (macOS & Linux)",
ps1_packages: "Script-only package (Windows)",
py_packages: "Script-only package (macOS & Linux)",
jetbrains_plugins: "IDE extension", // jetbrains_plugins can include any JetBrains IDE (e.g., IntelliJ, PyCharm, WebStorm), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
go_binaries: "Binary (Go)",
} as const;
Expand Down Expand Up @@ -332,11 +333,16 @@ export const INSTALLABLE_SOURCE_PLATFORM_CONVERSION = {
vscode_extensions: null,
sh_packages: "linux", // 4.76 Added support for Linux hosts only
ps1_packages: "windows",
py_packages: "linux", // stored as linux; also runs on macOS via the unix-like install exception
jetbrains_plugins: null,
go_binaries: null,
} as const;

export const SCRIPT_PACKAGE_SOURCES = ["sh_packages", "ps1_packages"];
export const SCRIPT_PACKAGE_SOURCES = [
"sh_packages",
"ps1_packages",
"py_packages",
];

/** Sources that don't map cleanly to versions or hosts in software inventory.
* UI behavior for these sources:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,60 @@ describe("Activity Feed", () => {
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
});

it("renders py script package ran status in InstalledSoftware activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Script Admin",
details: {
software_title: "Python Script Software",
source: "py_packages",
status: "installed",
software_package: "install.py",
host_display_name: "Example Host",
},
});

render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/ran/i)).toBeInTheDocument();
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
});

it("renders py script package pending run status in InstalledSoftware activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Script Admin",
details: {
software_title: "Python Script Software",
source: "py_packages",
status: "pending_install",
software_package: "install.py",
host_display_name: "Example Host",
},
});

render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/told Fleet to run/i)).toBeInTheDocument();
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
});

it("renders py script package failed run status in InstalledSoftware activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Script Admin",
details: {
software_title: "Python Script Software",
source: "py_packages",
status: "failed_install",
software_package: "install.py",
host_display_name: "Example Host",
},
});

render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/failed to run/i)).toBeInTheDocument();
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
});

it("renders addedNdesScepProxy activity correctly", () => {
const activity = createMockActivity({
type: ActivityType.AddedNdesScepProxy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { IconNames } from "components/icons";
import { ILabelSoftwareTitle } from "interfaces/label";
import { InstallerType } from "interfaces/software";
import { InstallerType, SoftwareSource } from "interfaces/software";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";

const baseClass = "library-item-accordion";
Expand Down Expand Up @@ -48,6 +48,9 @@ export interface ILibraryItemAccordionProps {
isLatestFmaVersion?: boolean;
/** Hide the version entirely (script-only packages). */
isScriptPackage?: boolean;
/** Software source, threaded to the installer widget to pick the file icon
* (e.g. `file-py` for `py_packages`). */
source?: SoftwareSource;
isTarballPackage?: boolean;
/** Apple App Store app whose platform is iOS or iPadOS. Drops the
* "policy automation" leg from the info-icon tooltip — `automatic_install`
Expand Down Expand Up @@ -119,6 +122,7 @@ const LibraryItemAccordion = ({
isFma = false,
isLatestFmaVersion,
isScriptPackage = false,
source,
isTarballPackage = false,
isIosOrIpadosApp = false,
isActive,
Expand Down Expand Up @@ -523,6 +527,7 @@ const LibraryItemAccordion = ({
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
source={source}
androidPlayStoreId={androidPlayStoreId}
hideInstallerType
// Inactive rows surface a single hover tooltip (the rollback hint);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ describe("InstallerDetailsWidget", () => {
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
});

it("renders the Python icon for a py_packages script package", () => {
render(<InstallerDetailsWidget {...defaultProps} source="py_packages" />);
expect(screen.queryByTestId("file-py-graphic")).toBeInTheDocument();
expect(screen.queryByTestId("file-pkg-graphic")).not.toBeInTheDocument();
});

it("renders the generic package icon for other script sources", () => {
render(<InstallerDetailsWidget {...defaultProps} source="sh_packages" />);
expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
expect(screen.queryByTestId("file-py-graphic")).not.toBeInTheDocument();
});

it("renders the software name", () => {
render(<InstallerDetailsWidget {...defaultProps} />);
expect(screen.getByText("Test Software")).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { internationalTimeFormat } from "utilities/helpers";
import { addedFromNow } from "utilities/date_format";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
import { InstallerType } from "interfaces/software";
import { InstallerType, SoftwareSource } from "interfaces/software";

import { isAndroidWebApp } from "pages/SoftwarePage/helpers";

Expand Down Expand Up @@ -74,6 +74,8 @@ interface IInstallerDetailsWidgetProps {
isFma: boolean;
isLatestFmaVersion?: boolean;
isScriptPackage: boolean;
/** Software source, used to pick the file icon (e.g. `file-py` for `py_packages`). */
source?: SoftwareSource;
androidPlayStoreId?: string;
customDetails?: string;
/** Suppress the leading installer-type label ("Custom package", "App Store (VPP)",
Expand All @@ -99,6 +101,7 @@ const InstallerDetailsWidget = ({
isFma,
isLatestFmaVersion = false,
isScriptPackage,
source,
androidPlayStoreId,
customDetails,
hideInstallerType = false,
Expand All @@ -113,6 +116,9 @@ const InstallerDetailsWidget = ({
}
return <SoftwareIcon name="appleAppStore" size="medium" />;
}
if (source === "py_packages") {
return <Graphic name="file-py" />;
}
return <Graphic name="file-pkg" />;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ const SoftwareTitleDetailsPage = ({
isFma={isFma}
isLatestFmaVersion={row.isActive && isLatestFmaVersion}
isScriptPackage={isScriptPackage}
source={title.source}
isTarballPackage={title.source === "tgz_packages"}
isIosOrIpadosApp={isIpadOrIphoneSoftwareSource(title.source)}
isActive={row.isActive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,43 @@ describe("SoftwareTitleDetailsPage helpers", () => {
isSelfService: true,
});
});
it("marks a py_packages title as a script package", () => {
const softwareTitle: ISoftwareTitleDetails = {
id: 1,
name: "Test Script",
icon_url: null,
versions: [],
software_package: {
labels_include_any: null,
labels_exclude_any: null,
labels_include_all: null,
name: "install.py",
title_id: 2,
version: "",
self_service: false,
uploaded_at: "2021-01-01T00:00:00Z",
status: {
installed: 1,
pending_install: 0,
pending_uninstall: 0,
failed_install: 0,
failed_uninstall: 0,
},
install_script: "#!/usr/bin/env python3\nprint('hi')",
uninstall_script: "",
icon_url: null,
automatic_install_policies: [],
url: "",
},
app_store_app: null,
source: "py_packages",
hosts_count: 0,
};
const packageCardInfo = getInstallerCardInfo(softwareTitle);
expect(packageCardInfo.source).toEqual("py_packages");
expect(packageCardInfo.isScriptPackage).toBe(true);
expect(packageCardInfo.name).toEqual("install.py");
});
it("returns the correct data for an app store app (and with a custom display name)", () => {
const softwareTitle: ISoftwareTitleDetails = {
id: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ const SoftwareDetailsSummary = ({
}
};

// Remove host count for tgz_packages, sh_packages, and ps1_packages only
// or if viewing details summary from edit icon preview modal
// Remove host count for sources without version/host data (tgz and script
// packages) or if viewing details summary from edit icon preview modal
const showHostCount =
!!hostCount && !NO_VERSION_OR_HOST_DATA_SOURCES.includes(source || "");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ const getGraphicName = (ext: string) => {
return "file-sh";
} else if (ext === "ps1") {
return "file-ps1";
} else if (ext === "py") {
return "file-py";
}
return "file-pkg";
};
Expand All @@ -90,14 +92,17 @@ const renderSoftwareDeployWarningBanner = () => (
const renderFileTypeMessage = () => {
return (
<>
macOS (.pkg,{" "}
<TooltipWrapper tipContent="Script-only package">.sh</TooltipWrapper>),
iOS/iPadOS (.ipa),
<br />
Windows (.msi, .exe,{" "}
<TooltipWrapper tipContent="Script-only package">.ps1</TooltipWrapper>),
or Linux (.deb, .rpm, .tar.gz,{" "}
<TooltipWrapper tipContent="Script-only package">.sh</TooltipWrapper>)
<TooltipWrapper tipContent="Supports .pkg, .sh, and .py">
macOS
</TooltipWrapper>
, <TooltipWrapper tipContent="Supports .ipa">iOS/iPadOS</TooltipWrapper>,{" "}
<TooltipWrapper tipContent="Supports .msi, .exe, .ps1">
Windows
</TooltipWrapper>
, or{" "}
<TooltipWrapper tipContent="Supports .deb, .rpm, .tar.gz, .sh, and .py">
Linux
</TooltipWrapper>
</>
);
};
Expand Down Expand Up @@ -126,7 +131,7 @@ interface IPackageFormProps {
}
// application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly
const ACCEPTED_EXTENSIONS =
".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.ipa";
".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.py,.ipa";

const PackageForm = ({
labels,
Expand Down
25 changes: 25 additions & 0 deletions frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ISetupStep } from "interfaces/setup";
import { isSoftwareScriptSetup } from "./helpers";

const setupStep = (source?: ISetupStep["source"]): ISetupStep => ({
name: "test",
status: "success",
type: "software_script_run",
source,
});

describe("DeviceUserPage helpers - isSoftwareScriptSetup", () => {
it("returns true for script package sources (sh, ps1, py)", () => {
expect(isSoftwareScriptSetup(setupStep("sh_packages"))).toBe(true);
expect(isSoftwareScriptSetup(setupStep("ps1_packages"))).toBe(true);
expect(isSoftwareScriptSetup(setupStep("py_packages"))).toBe(true);
});

it("returns false for non-script sources", () => {
expect(isSoftwareScriptSetup(setupStep("apps"))).toBe(false);
});

it("returns false when source is missing", () => {
expect(isSoftwareScriptSetup(setupStep(undefined))).toBe(false);
});
});
5 changes: 3 additions & 2 deletions frontend/pages/hosts/details/DeviceUserPage/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ISetupStep } from "interfaces/setup";
import { SCRIPT_PACKAGE_SOURCES } from "interfaces/software";

const DEFAULT_ERROR_MESSAGE = "refetch error.";

Expand Down Expand Up @@ -39,12 +40,12 @@ export const getFailedSoftwareInstall = (
return firstWithError ?? failedSoftware[0];
};

/** Checks if the software is a script-only package (sh or ps1)
/** Checks if the software is a script-only package (sh, ps1, or py)
* by examining the source field from the API */
export const isSoftwareScriptSetup = (s: ISetupStep) => {
if (!s.source) return false;

return s.source === "sh_packages" || s.source === "ps1_packages";
return SCRIPT_PACKAGE_SOURCES.includes(s.source);
};

// Hosts after enrollment during which we suppress the "host is offline" banner.
Expand Down
6 changes: 6 additions & 0 deletions frontend/utilities/file/fileUtils.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("fileUtils", () => {
{ fileName: "test.deb", expectedExtension: "deb" },
{ fileName: "test.rpm", expectedExtension: "rpm" },
{ fileName: "test.tar", expectedExtension: "tar" },
{ fileName: "test.py", expectedExtension: "py" },

// Compound extensions
{ fileName: "test.tar.gz", expectedExtension: "tar.gz" },
Expand Down Expand Up @@ -55,6 +56,10 @@ describe("fileUtils", () => {
fileName: "test.tar.gz",
expectedDetails: { name: "test.tar.gz", description: "Linux" },
},
{
fileName: "test.py",
expectedDetails: { name: "test.py", description: "macOS & Linux" },
},
{
fileName: "unknown.file",
expectedDetails: { name: "unknown.file", description: undefined },
Expand All @@ -79,6 +84,7 @@ describe("fileUtils", () => {
{ extension: "xml", platform: "Windows" },
{ extension: "deb", platform: "Linux" },
{ extension: "tar.gz", platform: "Linux" },
{ extension: "py", platform: "macOS & Linux" },
{ extension: undefined, platform: undefined }, // no extension
{ extension: "unknown_ext", platform: undefined }, // unmapped extension
];
Expand Down
1 change: 1 addition & 0 deletions frontend/utilities/file/fileUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record<
"tar.gz": "Linux",
sh: "macOS & Linux",
ps1: "Windows",
py: "macOS & Linux",
ipa: "iOS/iPadOS",
};

Expand Down
Loading
Loading