End user experience
After the end user continues past the Remote Management screen,
diff --git a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx
index 37a299bf0b..257b09365c 100644
--- a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx
@@ -183,7 +183,7 @@ const SoftwareOSDetailsPage = ({
name={osVersionDetails.platform}
/>
diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx
index f851e2cbf9..d52de58aa4 100644
--- a/frontend/pages/SoftwarePage/SoftwarePage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx
@@ -29,7 +29,7 @@ import TabsWrapper from "components/TabsWrapper";
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
import AddSoftwareModal from "./components/AddSoftwareModal";
-import { ISoftwareDropdownFilterVal } from "./SoftwareTitles/SoftwareTable/helpers";
+import { getSoftwareFilterFromQueryParams } from "./SoftwareTitles/SoftwareTable/helpers";
interface ISoftwareSubNavItem {
name: string;
@@ -63,16 +63,6 @@ const getTabIndex = (path: string): number => {
});
};
-const getSoftwareFilter = (
- vulnerable?: string,
- installable?: string
-): ISoftwareDropdownFilterVal => {
- if (installable === "true") return "installableSoftware";
- return vulnerable && vulnerable === "true"
- ? "vulnerableSoftware"
- : "allSoftware";
-};
-
// default values for query params used on this page if not provided
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
@@ -149,10 +139,11 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const query = queryParams && queryParams.query ? queryParams.query : "";
const showExploitedVulnerabilitiesOnly =
queryParams !== undefined && queryParams.exploit === "true";
- const softwareFilter = getSoftwareFilter(
- queryParams.vulnerable,
- queryParams.available_for_install
- );
+
+ // TODO: there should be better validation of the params depending on the route (e.g., self_service
+ // and available_for_install don't apply to versions, os, or vulnerabilities routes) and some
+ // defined redirect behavior if the params are invalid
+ const softwareFilter = getSoftwareFilterFromQueryParams(queryParams);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
index cea4bfc05d..665ea5ed6d 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
@@ -1,4 +1,9 @@
-import React, { useCallback, useContext, useState } from "react";
+import React, {
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useState,
+} from "react";
import FileSaver from "file-saver";
@@ -15,18 +20,58 @@ import { buildQueryStringFromParams } from "utilities/url";
import { internationalTimeFormat } from "utilities/helpers";
import { uploadedFromNow } from "utilities/date_format";
+// @ts-ignore
+import Dropdown from "components/forms/fields/Dropdown";
+
import Card from "components/Card";
import Graphic from "components/Graphic";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
-import Button from "components/buttons/Button";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import AdvancedOptionsModal from "../AdvancedOptionsModal";
const baseClass = "software-package-card";
+/** TODO: pull this hook and SoftwareName component out. We could use this other places */
+
+function useTruncatedElement(ref: any) {
+ const [isTruncated, setIsTruncated] = useState(false);
+
+ useLayoutEffect(() => {
+ const element = ref.current;
+ if (element) {
+ const { scrollWidth, clientWidth } = element;
+ setIsTruncated(scrollWidth > clientWidth);
+ }
+ }, [ref]);
+
+ return isTruncated;
+}
+
+interface ISoftwareNameProps {
+ name: string;
+}
+
+const SoftwareName = ({ name }: ISoftwareNameProps) => {
+ const titleRef = React.useRef(null);
+ const isTruncated = useTruncatedElement(titleRef);
+
+ return (
+
+
+ {name}
+
+
+ );
+};
+
interface IStatusDisplayOption {
displayName: string;
iconName: "success" | "pending-outline" | "error";
@@ -96,6 +141,59 @@ const PackageStatusCount = ({
);
};
+const DROPDOWN_OPTIONS = [
+ {
+ label: "Download",
+ value: "download",
+ },
+ {
+ label: "Delete",
+ value: "delete",
+ },
+ {
+ label: "Advanced options",
+ value: "advanced",
+ },
+] as const;
+
+const ActionsDropdown = ({
+ onDownloadClick,
+ onDeleteClick,
+ onAdvancedOptionsClick,
+}: {
+ onDownloadClick: () => void;
+ onDeleteClick: () => void;
+ onAdvancedOptionsClick: () => void;
+}) => {
+ const onSelect = (value: string) => {
+ switch (value) {
+ case "download":
+ onDownloadClick();
+ break;
+ case "delete":
+ onDeleteClick();
+ break;
+ case "advanced":
+ onAdvancedOptionsClick();
+ break;
+ default:
+ // noop
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
interface ISoftwarePackageCardProps {
softwarePackage: ISoftwarePackage;
softwareId: number;
@@ -115,7 +213,6 @@ const SoftwarePackageCard = ({
isTeamAdmin,
isTeamMaintainer,
} = useContext(AppContext);
-
const { renderFlash } = useContext(NotificationContext);
const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState(
@@ -171,16 +268,14 @@ const SoftwarePackageCard = ({
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
return (
-
+
{/* TODO: main-info could be a seperate component as its reused on a couple
pages already. Come back and pull this into a component */}
-
- {softwarePackage.name}
-
+
Version {softwarePackage.version} •
- {showActions && (
-
-
- {/* TODO: make a component for download icons */}
-
-
-
- )}
+
+ {true && (
+
+
+ Self-service
+
+ )}
+ {showActions && (
+
+ )}
+
{showAdvancedOptionsModal && (
.Select-menu-outer {
+ left: -120px;
+ }
+ .Select-placeholder {
+ color: $core-fleet-black;
+ }
+ }
+
&__download-icon {
display: flex;
justify-content: center;
@@ -62,7 +92,7 @@
&__main-content {
display: flex;
flex-direction: column;
- align-items: center;
+ align-items: flex-start;
gap: $pad-large;
}
}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
index 772d189ece..10d5b41fd0 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
@@ -12,7 +12,10 @@ import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
-import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
+import {
+ ISoftwareTitleWithPackageDetail,
+ formatSoftwareType,
+} from "interfaces/software";
import { ignoreAxiosError } from "interfaces/errors";
import softwareAPI, {
ISoftwareTitleResponse,
@@ -80,7 +83,7 @@ const SoftwareTitleDetailsPage = ({
} = useQuery<
ISoftwareTitleResponse,
AxiosError,
- ISoftwareTitle,
+ ISoftwareTitleWithPackageDetail,
IGetSoftwareTitleQueryKey[]
>(
[{ scope: "softwareById", softwareId, teamId: teamIdForApi }],
@@ -176,7 +179,7 @@ const SoftwareTitleDetailsPage = ({
/>
)}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
index 7ea1bd72ce..c67000d045 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
@@ -10,12 +10,19 @@ import { Row } from "react-table";
import PATHS from "router/paths";
import { getNextLocationPath } from "utilities/helpers";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
-import { buildQueryStringFromParams } from "utilities/url";
import {
+ buildQueryStringFromParams,
+ convertParamsToSnakeCase,
+} from "utilities/url";
+import {
+ ISoftwareApiParams,
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
-import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
+import {
+ ISoftwareTitleWithPackageName,
+ ISoftwareVersion,
+} from "interfaces/software";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
@@ -33,6 +40,7 @@ import {
ISoftwareDropdownFilterVal,
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
+ getSoftwareFilterForQueryKey,
} from "./helpers";
interface IRowProps extends Row {
@@ -154,7 +162,10 @@ const SoftwareTable = ({
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
);
- let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
+ let tableData:
+ | ISoftwareTitleWithPackageName[]
+ | ISoftwareVersion[]
+ | undefined;
let generateTableConfig: ITableConfigGenerator;
if (data === undefined) {
@@ -225,28 +236,23 @@ const SoftwareTable = ({
);
};
- const handleVulnFilterDropdownChange = (
+ const handleCustomFilterDropdownChange = (
value: ISoftwareDropdownFilterVal
) => {
- const queryParams: Record = {
+ const queryParams: ISoftwareApiParams = {
query,
- team_id: teamId,
- order_direction: orderDirection,
- order_key: orderKey,
+ teamId,
+ orderDirection,
+ orderKey,
page: 0, // resets page index
+ ...getSoftwareFilterForQueryKey(value),
};
- if (value === "installableSoftware") {
- queryParams.available_for_install = true;
- } else {
- queryParams.vulnerable = value === "vulnerableSoftware";
- }
-
router.replace(
getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
- queryParams,
+ queryParams: convertParamsToSnakeCase(queryParams),
})
);
};
@@ -304,7 +310,7 @@ const SoftwareTable = ({
className={`${baseClass}__vuln_dropdown`}
options={options}
searchable={false}
- onChange={handleVulnFilterDropdownChange}
+ onChange={handleCustomFilterDropdownChange}
tableFilterDropdown
/>
@@ -347,6 +353,9 @@ const SoftwareTable = ({
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
+ disablePagination={
+ !data?.meta.has_next_results && !data?.meta.has_previous_results
+ }
disableNextPage={!data?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx
index 9cf8e41e24..00839a7748 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx
@@ -2,7 +2,10 @@ import React from "react";
import { CellProps, Column } from "react-table";
import { InjectedRouter } from "react-router";
-import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
+import {
+ ISoftwareTitleWithPackageName,
+ formatSoftwareType,
+} from "interfaces/software";
import PATHS from "router/paths";
import { buildQueryStringFromParams } from "utilities/url";
@@ -19,17 +22,20 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
-type ISoftwareTitlesTableConfig = Column;
-type ITableStringCellProps = IStringCellProps;
-type IVersionsCellProps = CellProps;
+type ISoftwareTitlesTableConfig = Column;
+type ITableStringCellProps = IStringCellProps;
+type IVersionsCellProps = CellProps<
+ ISoftwareTitleWithPackageName,
+ ISoftwareTitleWithPackageName["versions"]
+>;
type IVulnerabilitiesCellProps = IVersionsCellProps;
type IHostCountCellProps = CellProps<
- ISoftwareTitle,
- ISoftwareTitle["hosts_count"]
+ ISoftwareTitleWithPackageName,
+ ISoftwareTitleWithPackageName["hosts_count"]
>;
-type IViewAllHostsLinkProps = CellProps;
+type IViewAllHostsLinkProps = CellProps;
-type ITableHeaderProps = IHeaderProps;
+type ITableHeaderProps = IHeaderProps;
export const getVulnerabilities = <
T extends { vulnerabilities: string[] | null }
@@ -63,7 +69,13 @@ const generateTableHeaders = (
disableSortBy: false,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
- const { id, name, source, software_package } = cellProps.row.original;
+ const {
+ id,
+ name,
+ source,
+ software_package,
+ self_service,
+ } = cellProps.row.original;
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
@@ -79,6 +91,7 @@ const generateTableHeaders = (
path={softwareTitleDetailsPath}
router={router}
hasPackage={hasPackage}
+ isSelfService={self_service === true}
/>
);
},
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
index 59fa04a46c..5cdc0fcde9 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
@@ -1,7 +1,10 @@
+import { QueryParams } from "utilities/url";
+
export type ISoftwareDropdownFilterVal =
| "allSoftware"
| "vulnerableSoftware"
- | "installableSoftware";
+ | "installableSoftware"
+ | "selfServiceSoftware";
export const SOFTWARE_VERSIONS_DROPDOWN_OPTIONS = [
{
@@ -27,4 +30,39 @@ export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [
value: "installableSoftware",
helpText: "Software that can be installed on your hosts.",
},
+ {
+ disabled: false,
+ label: "Self-service",
+ value: "selfServiceSoftware",
+ helpText: "Software that end users can install from Fleet Desktop.",
+ },
];
+
+export const getSoftwareFilterForQueryKey = (
+ val: ISoftwareDropdownFilterVal
+) => {
+ switch (val) {
+ case "installableSoftware":
+ return { availableForInstall: true };
+ case "selfServiceSoftware":
+ return { selfService: true };
+ case "vulnerableSoftware":
+ return { vulnerable: true };
+ default:
+ return {};
+ }
+};
+
+export const getSoftwareFilterFromQueryParams = (queryParams: QueryParams) => {
+ const { vulnerable, available_for_install, self_service } = queryParams;
+ switch (true) {
+ case available_for_install === "true":
+ return "installableSoftware";
+ case self_service === "true":
+ return "selfServiceSoftware";
+ case vulnerable === "true":
+ return "vulnerableSoftware";
+ default:
+ return "allSoftware";
+ }
+};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
index c057a28d76..1a90c284f5 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
@@ -5,11 +5,13 @@
import React from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
+import { omit } from "lodash";
import PATHS from "router/paths";
import softwareAPI, {
- ISoftwareApiParams,
+ ISoftwareTitlesQueryKey,
ISoftwareTitlesResponse,
+ ISoftwareVersionsQueryKey,
ISoftwareVersionsResponse,
} from "services/entities/software";
@@ -17,7 +19,10 @@ import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import SoftwareTable from "./SoftwareTable";
-import { ISoftwareDropdownFilterVal } from "./SoftwareTable/helpers";
+import {
+ ISoftwareDropdownFilterVal,
+ getSoftwareFilterForQueryKey,
+} from "./SoftwareTable/helpers";
const baseClass = "software-titles";
@@ -27,14 +32,6 @@ const QUERY_OPTIONS = {
staleTime: DATA_STALE_TIME,
};
-interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
- scope: "software-titles";
-}
-
-interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
- scope: "software-versions";
-}
-
interface ISoftwareTitlesProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
@@ -62,25 +59,6 @@ const SoftwareTitles = ({
}: ISoftwareTitlesProps) => {
const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS;
- const generateSoftwareTitlesQueryKey = (): ISoftwareTitlesQueryKey => {
- const queryKey: ISoftwareTitlesQueryKey = {
- scope: "software-titles",
- page: currentPage,
- perPage,
- query,
- orderDirection,
- orderKey,
- teamId,
- };
- if (softwareFilter === "installableSoftware") {
- queryKey.availableForInstall = true;
- } else {
- queryKey.vulnerable = softwareFilter === "vulnerableSoftware";
- }
-
- return queryKey;
- };
-
// request to get software data
const {
data: titlesData,
@@ -91,10 +69,22 @@ const SoftwareTitles = ({
ISoftwareTitlesResponse,
Error,
ISoftwareTitlesResponse,
- ISoftwareTitlesQueryKey[]
+ [ISoftwareTitlesQueryKey]
>(
- [generateSoftwareTitlesQueryKey()],
- ({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
+ [
+ {
+ scope: "software-titles",
+ page: currentPage,
+ perPage,
+ query,
+ orderDirection,
+ orderKey,
+ teamId,
+ ...getSoftwareFilterForQueryKey(softwareFilter),
+ },
+ ],
+ ({ queryKey: [queryKey] }) =>
+ softwareAPI.getSoftwareTitles(omit(queryKey, "scope")),
{
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_TITLES,
@@ -111,7 +101,7 @@ const SoftwareTitles = ({
ISoftwareVersionsResponse,
Error,
ISoftwareVersionsResponse,
- ISoftwareVersionsQueryKey[]
+ [ISoftwareVersionsQueryKey]
>(
[
{
@@ -125,7 +115,8 @@ const SoftwareTitles = ({
vulnerable: softwareFilter === "vulnerableSoftware",
},
],
- ({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
+ ({ queryKey: [queryKey] }) =>
+ softwareAPI.getSoftwareVersions(omit(queryKey, "scope")),
{
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_VERSIONS,
diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx
index 861a13ea95..328713f78c 100644
--- a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx
@@ -152,7 +152,7 @@ const SoftwareVersionDetailsPage = ({
source={softwareVersion.source}
/>
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx
index af8cb9e9bd..07028e7f75 100644
--- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx
@@ -68,7 +68,7 @@ const SoftwareVulnOSVersions = ({
};
return (
-
+
Vulnerable OS
{renderVulnerableOSTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx
index a1027bfb01..29ed8561d1 100644
--- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx
@@ -68,7 +68,7 @@ const SoftwareVulnSoftwareVersions = ({
);
};
return (
-
+
Vulnerable software
{renderVulnerableSoftwareTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx
index 7b55f67494..a2ce0c221c 100644
--- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx
@@ -38,7 +38,7 @@ const SoftwareVulnSummary = ({
} = vuln;
return (
-
+
{cve}
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx
index bb18ccf923..acad6e777d 100644
--- a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx
+++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx
@@ -1,15 +1,18 @@
-import React, { useState } from "react";
+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 Spinner from "components/Spinner";
import Button from "components/buttons/Button";
+import Checkbox from "components/forms/fields/Checkbox";
import Editor from "components/Editor";
-import FileUploader, {
+import {
+ FileUploader,
FileDetails,
} from "components/FileUploader/FileUploader";
-
-import { getFileDetails } from "utilities/file/fileUtils";
+import Spinner from "components/Spinner";
+import TooltipWrapper from "components/TooltipWrapper";
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
@@ -31,6 +34,7 @@ export interface IAddSoftwareFormData {
installScript: string;
preInstallCondition?: string;
postInstallScript?: string;
+ selfService: boolean;
}
export interface IFormValidation {
@@ -38,6 +42,7 @@ export interface IFormValidation {
software: { isValid: boolean };
preInstallCondition?: { isValid: boolean; message?: string };
postInstallScript?: { isValid: boolean; message?: string };
+ selfService?: { isValid: boolean };
}
interface IAddSoftwareFormProps {
@@ -51,6 +56,8 @@ const AddSoftwareForm = ({
onCancel,
onSubmit,
}: IAddSoftwareFormProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+
const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
const [showPostInstallScript, setShowPostInstallScript] = useState(false);
const [formData, setFormData] = useState({
@@ -58,6 +65,7 @@ const AddSoftwareForm = ({
installScript: "",
preInstallCondition: undefined,
postInstallScript: undefined,
+ selfService: false,
});
const [formValidation, setFormValidation] = useState({
isValid: false,
@@ -67,10 +75,19 @@ const AddSoftwareForm = ({
const onFileUpload = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
+
+ let installScript: string;
+ try {
+ installScript = getInstallScript(file.name);
+ } catch (e) {
+ renderFlash("error", `${e}`);
+ return;
+ }
+
const newData = {
...formData,
software: file,
- installScript: getInstallScript(file.name),
+ installScript,
};
setFormData(newData);
setFormValidation(
@@ -134,6 +151,18 @@ const AddSoftwareForm = ({
);
};
+ const onToggleSelfServiceCheckbox = (value: boolean) => {
+ const newData = { ...formData, selfService: value };
+ setFormData(newData);
+ setFormValidation(
+ generateFormValidation(
+ newData,
+ showPreInstallCondition,
+ showPostInstallScript
+ )
+ );
+ };
+
const isSubmitDisabled = !formValidation.isValid;
return (
@@ -175,6 +204,21 @@ const AddSoftwareForm = ({
}
/>
)}
+
+
+ End users can install from{" "}
+ Fleet Desktop {">"} Self-service.
+ >
+ }
+ >
+ Self-service
+
+
- {formData.software?.name} successfully added. Go to Host
- details page to install software.
+ {formData.software?.name} successfully added.
+ {formData.selfService
+ ? " The end user can install from Fleet Desktop."
+ : ""}
>
);
onExit();
+
+ const newQueryParams: QueryParams = { team_id: teamId };
+ if (formData.selfService) {
+ newQueryParams.self_service = true;
+ } else {
+ newQueryParams.available_for_install = true;
+ }
+
router.push(
- `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
- available_for_install: true,
- team_id: teamId,
- })}`
+ `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
);
} catch (e) {
renderFlash("error", getErrorReason(e));
diff --git a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx
index 6c1c62a60b..94187808c7 100644
--- a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx
+++ b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx
@@ -11,7 +11,7 @@ interface IDetailsNoHosts {
const DetailsNoHosts = ({ header, details }: IDetailsNoHosts) => {
return (
-
+
{header}
{details}
diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss
index 3a273a7046..5006d85624 100644
--- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss
@@ -10,8 +10,6 @@
.software-icon {
width: 96px;
height: 96px;
- border: 1px solid $ui-fleet-black-10;
- border-radius: 8px;
}
&__info {
diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss
index 75a962198d..328a564a1e 100644
--- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss
@@ -1,3 +1,5 @@
.software-icon {
flex-shrink: 0;
+ border: 1px solid $ui-fleet-black-10;
+ border-radius: 8px;
}
diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts
index 4e7e910a30..7adf14e68c 100644
--- a/frontend/pages/SoftwarePage/components/icons/index.ts
+++ b/frontend/pages/SoftwarePage/components/icons/index.ts
@@ -73,6 +73,7 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = {
export const SOFTWARE_ICON_SIZES: Record = {
medium: "24",
+ meduim_large: "64", // TODO: rename this to large and update large to xlarge
large: "96",
} as const;
diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx
index 74b1723dcc..114b537d3b 100644
--- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx
@@ -10,6 +10,8 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [
"policy_response",
"macos_settings",
"software_id",
+ "software_version_id",
+ "software_title_id",
HOSTS_QUERY_PARAMS.SOFTWARE_STATUS,
"status",
"mdm_id",
diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx
index e2f2055a56..b052371d50 100644
--- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx
+++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx
@@ -15,6 +15,7 @@ import {
} from "interfaces/host";
import { IHostPolicy } from "interfaces/policy";
import { IDeviceGlobalConfig } from "interfaces/config";
+
import DeviceUserError from "components/DeviceUserError";
// @ts-ignore
import OrgLogoIcon from "components/icons/OrgLogoIcon";
@@ -45,9 +46,22 @@ import OSSettingsModal from "../OSSettingsModal";
import ResetKeyModal from "./ResetKeyModal";
import BootstrapPackageModal from "../HostDetailsPage/modals/BootstrapPackageModal";
import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
+import SelfService from "../cards/Software/SelfService";
const baseClass = "device-user";
+const PREMIUM_TABS = [
+ PATHS.DEVICE_USER_DETAILS,
+ PATHS.DEVICE_USER_DETAILS_SELF_SERVICE,
+ PATHS.DEVICE_USER_DETAILS_SOFTWARE,
+ PATHS.DEVICE_USER_DETAILS_POLICIES,
+] as const;
+
+const FREE_TABS = [
+ PATHS.DEVICE_USER_DETAILS,
+ PATHS.DEVICE_USER_DETAILS_SOFTWARE,
+] as const;
+
interface IDeviceUserPageProps {
location: {
pathname: string;
@@ -80,6 +94,7 @@ const DeviceUserPage = ({
const [refetchStartTime, setRefetchStartTime] = useState(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [orgLogoURL, setOrgLogoURL] = useState("");
+ const [orgContactURL, setOrgContactURL] = useState("");
const [selectedPolicy, setSelectedPolicy] = useState(
null
);
@@ -152,15 +167,19 @@ const DeviceUserPage = ({
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
+ // TODO: refactor to use non-refetch data directly in the component and remove
+ // unnecesary derived states for values that aren't related to the refetch status
onSuccess: ({
license,
org_logo_url,
+ org_contact_url,
global_config,
host: responseHost,
}) => {
setShowRefetchSpinner(isRefetching(responseHost));
setIsPremiumTier(license.tier === "premium");
setOrgLogoURL(org_logo_url);
+ setOrgContactURL(org_contact_url);
setGlobalConfig(global_config);
if (isRefetching(responseHost)) {
// If the API reports that a Fleet refetch request is pending, we want to check back for fresh
@@ -324,14 +343,17 @@ const DeviceUserPage = ({
host?.mdm.macos_settings?.disk_encryption === "action_required" &&
host?.mdm.macos_settings?.action_required === "rotate_key";
- const tabPaths = [
- PATHS.DEVICE_USER_DETAILS(deviceAuthToken),
- PATHS.DEVICE_USER_DETAILS_SOFTWARE(deviceAuthToken),
- PATHS.DEVICE_USER_DETAILS_POLICIES(deviceAuthToken),
- ];
-
+ // TODO: We should probably have a standard way to handle this on all pages. Do we want to show
+ // a premium-only message in the case that a user tries direct navigation to a premium-only page
+ // or silently redirect as below?
+ const tabPaths = isPremiumTier
+ ? PREMIUM_TABS.map((t) => t(deviceAuthToken))
+ : FREE_TABS.map((t) => t(deviceAuthToken));
const findSelectedTab = (pathname: string) =>
findIndex(tabPaths, (x) => x.startsWith(pathname.split("?")[0]));
+ if (!isLoadingHost && host && findSelectedTab(location.pathname) === -1) {
+ router.push(tabPaths[0]);
+ }
// TODO: This is a temporary fix that conditionally shows the new software tab depending on
// whether software items returned in the device details response (legacy endpoint).
@@ -394,6 +416,7 @@ const DeviceUserPage = ({
>
Details
+ {isPremiumTier && Self-service}
{isSoftwareEnabled && Software}
{isPremiumTier && (
@@ -413,6 +436,18 @@ const DeviceUserPage = ({
munki={deviceMacAdminsData?.munki}
/>
+ {isPremiumTier && (
+
+
+
+ )}
{isSoftwareEnabled && (
{
- Upcoming activities will run as listed. Failure of one activity won’t
- cancel other activities.
+ Upcoming activities will run as listed. Failure of one activity
+ won't cancel other activities.
Currently, only scripts are guaranteed to run in order.
@@ -47,7 +47,7 @@ const UpcomingTooltip = () => {
interface IActivityProps {
activeTab: "past" | "upcoming";
- activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse;
+ activities?: IHostPastActivitiesResponse | IHostUpcomingActivitiesResponse;
isLoading?: boolean;
isError?: boolean;
upcomingCount: number;
@@ -71,7 +71,7 @@ const Activity = ({
// TODO: add count to upcoming activities tab when available via API
return (
| React.FC
> = {
@@ -34,3 +37,12 @@ export const pastActivityComponentMap: Record<
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
};
+
+export const upcomingActivityComponentMap: Record<
+ IHostUpcomingActivityType,
+ | React.FC
+ | React.FC
+> = {
+ [ActivityType.RanScript]: RanScriptActivityItem,
+ [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
+};
diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx
index fd0d7d3fa6..b5edd5f4b5 100644
--- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx
@@ -31,12 +31,17 @@ const InstalledSoftwareActivityItem = ({
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
const { actor_full_name: actorName, details } = activity;
+ const { self_service, status, software_title: title } = details;
- const { status, software_title: title } = details;
+ const actorDisplayName = self_service ? (
+ An end user
+ ) : (
+ {actorName}
+ );
return (
- {actorName} {getSoftwareInstallStatusPredicate(status)}{" "}
+ <>{actorDisplayName}> {getSoftwareInstallStatusPredicate(status)}{" "}
{title} software on this host.{" "}
diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
index 94b1a8dc7c..e0f26d0a8a 100644
--- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
@@ -9,16 +9,20 @@ import ShowDetailsButton from "../../ShowDetailsButton";
const baseClass = "ran-script-activity-item";
const RanScriptActivityItem = ({
+ tab,
activity,
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
+ const ranScriptPrefix = tab === "past" ? "ran" : "told Fleet to run";
+
return (
{activity.actor_full_name}
<>
{" "}
- ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
- this host.{" "}
+ {ranScriptPrefix}{" "}
+ {formatScriptNameForActivityItem(activity.details?.script_name)} on this
+ host.{" "}
>
diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
index 97f0346141..5e58a50f4c 100644
--- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx
@@ -1,7 +1,7 @@
import React from "react";
-import { IHostActivity } from "interfaces/activity";
-import { IHostActivitiesResponse } from "services/entities/activities";
+import { IHostPastActivity } from "interfaces/activity";
+import { IHostPastActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@@ -16,7 +16,7 @@ import { pastActivityComponentMap } from "../ActivityConfig";
const baseClass = "past-activity-feed";
interface IPastActivityFeedProps {
- activities?: IHostActivitiesResponse;
+ activities?: IHostPastActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@@ -53,11 +53,12 @@ const PastActivityFeed = ({
return (
- {activitiesList.map((activity: IHostActivity) => {
+ {activitiesList.map((activity: IHostPastActivity) => {
const ActivityItemComponent = pastActivityComponentMap[activity.type];
return (
diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx
deleted file mode 100644
index 8a202e4de9..0000000000
--- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import React from "react";
-import ReactTooltip from "react-tooltip";
-import { formatDistanceToNowStrict } from "date-fns";
-
-import { ActivityType, IHostActivity } from "interfaces/activity";
-import { COLORS } from "styles/var/colors";
-import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
-import {
- addGravatarUrlToResource,
- formatScriptNameForActivityItem,
- internationalTimeFormat,
-} from "utilities/helpers";
-
-import Avatar from "components/Avatar";
-import Icon from "components/Icon";
-import Button from "components/buttons/Button";
-import { ShowActivityDetailsHandler } from "../Activity";
-
-const baseClass = "upcoming-activity";
-
-interface IUpcomingActivityProps {
- activity: IHostActivity;
- onDetailsClick: ShowActivityDetailsHandler;
-}
-
-const formatPredicate = ({ type, details }: IHostActivity) => {
- switch (type) {
- case ActivityType.RanScript:
- return (
- <>
- told Fleet to run{" "}
- {formatScriptNameForActivityItem(details?.script_name)}
- >
- );
- case ActivityType.InstalledSoftware:
- return (
- <>
- told Fleet to install{" "}
- {details?.software_title ? (
- <>
-
{details.software_title}{" "}
- >
- ) : (
- ""
- )}
- software
- >
- );
- default:
- // this should never happen
- return <>{type}>;
- }
-};
-
-// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
-// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
-const UpcomingActivity = ({
- activity,
- onDetailsClick,
-}: IUpcomingActivityProps) => {
- const { actor_email } = activity;
- const { gravatar_url } = actor_email
- ? addGravatarUrlToResource({ email: actor_email })
- : { gravatar_url: DEFAULT_GRAVATAR_LINK };
- const activityCreatedAt = new Date(activity.created_at);
-
- return (
-
-
-
-
-
- {activity.actor_full_name} {formatPredicate(activity)} on
- this host.{" "}
-
-
-
-
- {formatDistanceToNowStrict(activityCreatedAt, {
- addSuffix: true,
- })}
-
-
- {internationalTimeFormat(activityCreatedAt)}
-
-
-
-
-
- );
-};
-
-export default UpcomingActivity;
diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss
deleted file mode 100644
index afaa538a71..0000000000
--- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss
+++ /dev/null
@@ -1,65 +0,0 @@
-.upcoming-activity {
- display: grid; // Grid system is used to create variable dashed line lengths
- grid-template-columns: 16px 16px 1fr;
- grid-template-rows: 32px max-content;
-
- .avatar-wrapper {
- grid-column-start: 1;
- width: 32px;
- height: 32px;
- }
-
- &__dash {
- border-right: 1px dashed $ui-fleet-black-10;
- grid-column-start: 1;
- grid-row-start: 2;
- grid-row-end: 3;
- }
-
- &__details-wrapper {
- grid-column-start: 3;
- grid-row-start: 1;
- grid-row-end: 3;
- padding-left: $pad-large;
- padding-bottom: $pad-large;
-
- .premium-icon-tip {
- position: relative;
- top: 4px;
- padding-right: $pad-xsmall;
- }
-
- .activity-details {
- margin: 0;
- line-height: 16px;
- }
- }
-
- &__details-topline {
- font-size: $x-small;
- overflow-wrap: anywhere;
- }
-
- &__details-content {
- margin-right: $pad-xsmall;
- }
-
- &__details-bottomline {
- font-size: $xx-small;
- color: $ui-fleet-black-25;
- }
-
- &__show-query-icon {
- margin-left: $pad-xsmall;
- }
-
- &:last-child {
- .upcoming-activity__dash {
- border-right: none;
- }
-
- .upcoming-activity__details {
- padding-bottom: $pad-xxlarge;
- }
- }
-}
diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts
deleted file mode 100644
index 413a03e29a..0000000000
--- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./UpcomingActivity";
diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx
index 32064a42ba..c9c12696f2 100644
--- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx
@@ -1,7 +1,7 @@
import React from "react";
-import { IHostActivity } from "interfaces/activity";
-import { IUpcomingActivitiesResponse } from "services/entities/activities";
+import { IHostUpcomingActivity } from "interfaces/activity";
+import { IHostUpcomingActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@@ -9,13 +9,13 @@ import DataError from "components/DataError";
import Button from "components/buttons/Button";
import EmptyFeed from "../EmptyFeed/EmptyFeed";
-import UpcomingActivity from "../UpcomingActivity/UpcomingActivity";
import { ShowActivityDetailsHandler } from "../Activity";
+import { upcomingActivityComponentMap } from "../ActivityConfig";
const baseClass = "upcoming-activity-feed";
interface IUpcomingActivityFeedProps {
- activities?: IUpcomingActivitiesResponse;
+ activities?: IHostUpcomingActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@@ -52,13 +52,18 @@ const UpcomingActivityFeed = ({
return (
- {activitiesList.map((activity: IHostActivity) => (
-
- ))}
+ {activitiesList.map((activity: IHostUpcomingActivity) => {
+ const ActivityItemComponent =
+ upcomingActivityComponentMap[activity.type];
+ return (
+
+ );
+ })}
- {displayConfig.tooltip(packageToInstall, installedAt)}
+ {displayConfig.tooltip({
+ softwareName,
+ lastInstalledAt,
+ })}
-
{displayConfig.displayText}
);
};
diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss
index 7384269a1e..290e151cc0 100644
--- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss
+++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss
@@ -5,6 +5,11 @@
gap: $pad-small;
}
+ &__status-with-tooltip {
+ display: flex;
+ gap: $pad-small;
+ }
+
&__status-tooltip {
text-align: center;
}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx
new file mode 100644
index 0000000000..f83f9d3b0d
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx
@@ -0,0 +1,152 @@
+import React, { useCallback } from "react";
+import { useQuery } from "react-query";
+import { InjectedRouter } from "react-router";
+import { AxiosError } from "axios";
+
+import deviceApi, {
+ IDeviceSoftwareQueryKey,
+ IGetDeviceSoftwareResponse,
+} from "services/entities/device_user";
+
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+
+import Card from "components/Card";
+import CustomLink from "components/CustomLink";
+import DataError from "components/DataError";
+import EmptyTable from "components/EmptyTable";
+import Spinner from "components/Spinner";
+
+import Pagination from "pages/ManageControlsPage/components/Pagination";
+
+import { parseHostSoftwareQueryParams } from "../HostSoftware";
+import SelfServiceItem from "./SelfServiceItem";
+
+const baseClass = "software-self-service";
+
+// These default params are not subject to change by the user
+const DEFAULT_SELF_SERVICE_QUERY_PARAMS = {
+ per_page: 9,
+ order_key: "name",
+ order_direction: "asc",
+ query: "",
+ self_service: true,
+} as const;
+
+const SoftwareSelfService = ({
+ contactUrl,
+ deviceToken,
+ isSoftwareEnabled,
+ pathname,
+ queryParams,
+ router,
+}: {
+ contactUrl: string; // TODO: confirm this has been added to the device API response
+ deviceToken: string;
+ isSoftwareEnabled?: boolean;
+ pathname: string;
+ queryParams: ReturnType
;
+ router: InjectedRouter;
+}) => {
+ // TOOD: loading state for fetching?
+ const { data, isLoading, isError, refetch } = useQuery<
+ IGetDeviceSoftwareResponse,
+ AxiosError,
+ IGetDeviceSoftwareResponse,
+ IDeviceSoftwareQueryKey[]
+ >(
+ [
+ {
+ scope: "device_software",
+ id: deviceToken,
+ page: queryParams.page,
+ ...DEFAULT_SELF_SERVICE_QUERY_PARAMS,
+ },
+ ],
+ ({ queryKey }) => deviceApi.getDeviceSoftware(queryKey[0]),
+ {
+ ...DEFAULT_USE_QUERY_OPTIONS,
+ enabled: isSoftwareEnabled,
+ keepPreviousData: true,
+ staleTime: 7000,
+ }
+ );
+
+ const onNextPage = useCallback(() => {
+ router.push(pathname.concat(`?page=${queryParams.page + 1}`));
+ }, [pathname, queryParams.page, router]);
+
+ const onPrevPage = useCallback(() => {
+ router.push(pathname.concat(`?page=${queryParams.page - 1}`));
+ }, [pathname, queryParams.page, router]);
+
+ // TODO: handle empty state better, this is just a placeholder for now
+ // TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the
+ // available results)?
+ const isEmpty = !data?.software.length && !data?.meta.has_previous_results;
+
+ return (
+
+ Self-service
+
+ Install organization-approved apps provided by your IT department.{" "}
+ {contactUrl && (
+
+ If you need help,{" "}
+
+
+ )}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {isError && }
+ {!isError && (
+
+ {isEmpty ? (
+
+ ) : (
+ <>
+
+ {data.count} items
+
+
+ {data.software.map((s) => {
+ const key = `${s.id}${s.last_install?.install_uuid}`; // concatenating install_uuid so item updates with fresh data on refetch
+ return (
+
+ );
+ })}
+
+
+ >
+ )}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default SoftwareSelfService;
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx
new file mode 100644
index 0000000000..d04e235e66
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx
@@ -0,0 +1,221 @@
+import React, { useCallback, useContext, useEffect, useRef } from "react";
+import ReactTooltip from "react-tooltip";
+
+import {
+ IDeviceSoftware,
+ IHostSoftware,
+ SoftwareInstallStatus,
+} from "interfaces/software";
+import deviceApi from "services/entities/device_user";
+import { dateAgo } from "utilities/date_format";
+import { NotificationContext } from "context/notification";
+
+import Card from "components/Card";
+import Button from "components/buttons/Button";
+import Icon from "components/Icon";
+import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
+
+import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell";
+
+const baseClass = "self-service-item";
+
+const STATUS_CONFIG: Record = {
+ installed: {
+ iconName: "success",
+ displayText: "Installed",
+ tooltip: ({ lastInstalledAt }) => (
+ <>
+ Software installed successfully ({dateAgo(lastInstalledAt as string)}).
+ >
+ ),
+ },
+ pending: {
+ iconName: "pending-outline",
+ displayText: "Install in progress...",
+ tooltip: () => "Software installation in progress...",
+ },
+ failed: {
+ iconName: "error",
+ displayText: "Failed",
+ tooltip: ({ lastInstalledAt = "" }) => (
+ <>
+ Software failed to install
+ {lastInstalledAt ? `(${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
+ Retry to install again, or contact your IT department.
+ >
+ ),
+ },
+};
+
+interface IInstallerInfoProps {
+ software: IDeviceSoftware;
+}
+
+const InstallerInfo = ({ software }: IInstallerInfoProps) => {
+ const { name, source, package: installerPackage } = software;
+ return (
+
+
+
+
+
+
+ {name || installerPackage?.name}
+
+
+ {installerPackage?.version || ""}
+
+
+
+ );
+};
+
+type IInstallerStatusProps = Pick<
+ IHostSoftware,
+ "id" | "status" | "last_install"
+>;
+
+const InstallerStatus = ({
+ id,
+ status,
+ last_install,
+}: IInstallerStatusProps) => {
+ const displayConfig = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG];
+ if (!displayConfig) {
+ // API should ensure this never happens, but just in case
+ return null;
+ }
+
+ return (
+
+
+
+ {displayConfig.displayText}
+
+
+
+ {displayConfig.tooltip({
+ lastInstalledAt: last_install?.installed_at,
+ })}
+
+
+
+ );
+};
+
+interface IInstallerStatusActionProps {
+ deviceToken: string;
+ software: IHostSoftware;
+ onInstall: () => void;
+}
+
+const InstallerStatusAction = ({
+ deviceToken,
+ software: { id, status, last_install },
+ onInstall,
+}: IInstallerStatusActionProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+
+ // localStatus is used to track the status of the any user-initiated install action
+ const [localStatus, setLocalStatus] = React.useState<
+ SoftwareInstallStatus | undefined
+ >(undefined);
+
+ // displayStatus allows us to display the localStatus (if any) or the status from the list
+ // software reponse
+ const displayStatus = localStatus || status;
+
+ // if the localStatus is "failed", we don't our tooltip to include the old installed_at date so we
+ // set this to null, which tells the tooltip to omit the parenthetical date
+ const lastInstall = localStatus === "failed" ? null : last_install;
+
+ const isMountedRef = useRef(false);
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const onClick = useCallback(async () => {
+ setLocalStatus("pending");
+ try {
+ await deviceApi.installSelfServiceSoftware(deviceToken, id);
+ if (isMountedRef.current) {
+ onInstall();
+ }
+ } catch (error) {
+ renderFlash("error", "Couldn't install. Please try again.");
+ if (isMountedRef.current) {
+ setLocalStatus("failed");
+ }
+ }
+ }, [deviceToken, id, onInstall, renderFlash]);
+
+ return (
+
+
+
+
+
+ {(displayStatus === "failed" || displayStatus === null) && (
+
+ )}
+
+
+ );
+};
+
+interface ISelfServiceItemProps {
+ deviceToken: string;
+ software: IDeviceSoftware;
+ onInstall: () => void;
+}
+
+const SelfServiceItem = ({
+ deviceToken,
+ software,
+ onInstall,
+}: ISelfServiceItemProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SelfServiceItem;
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss
new file mode 100644
index 0000000000..56d357f798
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss
@@ -0,0 +1,97 @@
+.self-service-item {
+ &__item {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__item-content {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__item-topline {
+ display: flex;
+ flex-direction: row;
+ height: 64px;
+ align-items: center;
+ gap: 16px;
+ overflow: hidden;
+ }
+
+ &__item-icon {
+ display: flex;
+ height: 64px;
+ min-width: 64px;
+ }
+
+ &__item-name-version {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 64px;
+ overflow: hidden;
+ }
+
+ &__item-name {
+ font-size: $x-small;
+ font-weight: $bold;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-wrap: nowrap;
+ }
+
+ &__item-version {
+ font-size: $xx-small;
+ color: $ui-fleet-black-75;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-wrap: nowrap;
+ }
+
+ &__item-status-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding-top: 16px;
+ border-top: 1px solid $ui-fleet-black-10;
+ }
+
+ &__item-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__item-action {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__status-content {
+ display: flex;
+ align-items: center;
+ gap: $pad-small;
+ }
+
+ &__status-with-tooltip {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: $pad-small;
+
+ span {
+ font-size: $x-small;
+ }
+ }
+
+ &__item-action-button {
+ height: auto;
+
+ &--installing {
+ display: none;
+ }
+ }
+}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts
new file mode 100644
index 0000000000..eebc870793
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts
@@ -0,0 +1 @@
+export { default } from "./SelfServiceItem";
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss
new file mode 100644
index 0000000000..484312533c
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss
@@ -0,0 +1,35 @@
+.software-self-service {
+ &__card-header {
+ margin: 0 0 8px 0;
+ }
+
+ &__card-subheader {
+ margin: 0 0 24px 0;
+ color: $ui-fleet-black-75;
+ font-size: $x-small;
+ }
+
+ // TODO: empty table styling differs slightly from figma (font size, color, spacing), why?g
+ .empty-table__container {
+ margin: 64px 0;
+ }
+
+ &__items-count {
+ margin: 0 0 24px 0;
+ font-size: $x-small;
+ font-weight: $bold;
+ }
+
+ &__items {
+ display: grid;
+ gap: $pad-large;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ }
+
+ &__pagination {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 16px;
+ }
+}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts
new file mode 100644
index 0000000000..8ee96ff078
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts
@@ -0,0 +1 @@
+export { default } from "./SelfService";
diff --git a/frontend/pages/hosts/details/cards/Users/Users.tsx b/frontend/pages/hosts/details/cards/Users/Users.tsx
index fc738949ee..ed7c01ad56 100644
--- a/frontend/pages/hosts/details/cards/Users/Users.tsx
+++ b/frontend/pages/hosts/details/cards/Users/Users.tsx
@@ -31,7 +31,7 @@ const Users = ({
if (!hostUsersEnabled) {
return (
+
diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts
index 697622269d..ae2b39b2fd 100644
--- a/frontend/router/paths.ts
+++ b/frontend/router/paths.ts
@@ -137,6 +137,9 @@ export default {
DEVICE_USER_DETAILS: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}`;
},
+ DEVICE_USER_DETAILS_SELF_SERVICE: (deviceAuthToken: string): string => {
+ return `${URL_PREFIX}/device/${deviceAuthToken}/self-service`;
+ },
DEVICE_USER_DETAILS_SOFTWARE: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}/software`;
},
diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts
index 61123bddc4..3aa2a263c5 100644
--- a/frontend/services/entities/activities.ts
+++ b/frontend/services/entities/activities.ts
@@ -1,5 +1,9 @@
import endpoints from "utilities/endpoints";
-import { IActivity, IHostActivity } from "interfaces/activity";
+import {
+ IActivity,
+ IHostPastActivity,
+ IHostUpcomingActivity,
+} from "interfaces/activity";
import sendRequest from "services";
import { buildQueryStringFromParams } from "utilities/url";
@@ -16,16 +20,21 @@ export interface IActivitiesResponse {
};
}
-export interface IHostActivitiesResponse {
- activities: IHostActivity[] | null;
+export interface IHostPastActivitiesResponse {
+ activities: IHostPastActivity[] | null;
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
-export interface IUpcomingActivitiesResponse extends IHostActivitiesResponse {
+export interface IHostUpcomingActivitiesResponse {
count: number;
+ activities: IHostUpcomingActivity[] | null;
+ meta: {
+ has_next_results: boolean;
+ has_previous_results: boolean;
+ };
}
export default {
@@ -53,7 +62,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
- ): Promise => {
+ ): Promise => {
const { HOST_PAST_ACTIVITIES } = endpoints;
const queryParams = {
@@ -72,7 +81,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
- ): Promise => {
+ ): Promise => {
const { HOST_UPCOMING_ACTIVITIES } = endpoints;
const queryParams = {
diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts
index d0de571946..33955a419d 100644
--- a/frontend/services/entities/device_user.ts
+++ b/frontend/services/entities/device_user.ts
@@ -1,5 +1,5 @@
import { IDeviceUserResponse } from "interfaces/host";
-import { IHostSoftware } from "interfaces/software";
+import { IDeviceSoftware } from "interfaces/software";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
@@ -13,7 +13,7 @@ export interface IDeviceSoftwareQueryKey extends IHostSoftwareQueryParams {
}
export interface IGetDeviceSoftwareResponse {
- software: IHostSoftware[];
+ software: IDeviceSoftware[];
count: number;
meta: {
has_next_results: boolean;
@@ -53,4 +53,14 @@ export default {
const queryString = buildQueryStringFromParams(rest);
return sendRequest("GET", `${DEVICE_SOFTWARE(id)}?${queryString}`);
},
+
+ installSelfServiceSoftware: (
+ deviceToken: string,
+ softwareTitleId: number
+ ) => {
+ const { DEVICE_SOFTWARE_INSTALL } = endpoints;
+ const path = DEVICE_SOFTWARE_INSTALL(deviceToken, softwareTitleId);
+
+ return sendRequest("POST", path);
+ },
};
diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts
index c5c8be0f44..ac1f71673f 100644
--- a/frontend/services/entities/hosts.ts
+++ b/frontend/services/entities/hosts.ts
@@ -12,7 +12,6 @@ import {
import { SelectedPlatform } from "interfaces/platform";
import {
IHostSoftware,
- ISoftwareTitle,
ISoftware,
SoftwareInstallStatus,
} from "interfaces/software";
@@ -34,7 +33,7 @@ export interface ISortOption {
export interface ILoadHostsResponse {
hosts: IHost[];
software: ISoftware | undefined;
- software_title: ISoftwareTitle | undefined;
+ software_title: { name: string; version?: string } | null | undefined; // TODO: confirm type
munki_issue: IMunkiIssuesAggregate;
mobile_device_management_solution: IMdmSolution;
}
diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts
index 40be25ddc5..3b4067dd7d 100644
--- a/frontend/services/entities/software.ts
+++ b/frontend/services/entities/software.ts
@@ -1,16 +1,18 @@
import { AxiosResponse } from "axios";
-import { snakeCase, reduce } from "lodash";
-
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import {
ISoftwareResponse,
ISoftwareCountResponse,
ISoftwareVersion,
- ISoftwareTitle,
+ ISoftwareTitleWithPackageDetail,
+ ISoftwareTitleWithPackageName,
} from "interfaces/software";
-import { buildQueryStringFromParams, QueryParams } from "utilities/url";
+import {
+ buildQueryStringFromParams,
+ convertParamsToSnakeCase,
+} from "utilities/url";
import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm";
@@ -22,13 +24,14 @@ export interface ISoftwareApiParams {
query?: string;
vulnerable?: boolean;
availableForInstall?: boolean;
+ selfService?: boolean;
teamId?: number;
}
export interface ISoftwareTitlesResponse {
counts_updated_at: string | null;
count: number;
- software_titles: ISoftwareTitle[];
+ software_titles: ISoftwareTitleWithPackageName[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
@@ -46,13 +49,21 @@ export interface ISoftwareVersionsResponse {
}
export interface ISoftwareTitleResponse {
- software_title: ISoftwareTitle;
+ software_title: ISoftwareTitleWithPackageDetail;
}
export interface ISoftwareVersionResponse {
software: ISoftwareVersion;
}
+export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
+ scope: "software-versions";
+}
+
+export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
+ scope: "software-titles";
+}
+
export interface ISoftwareQueryKey extends ISoftwareApiParams {
scope: "software";
}
@@ -85,17 +96,6 @@ export interface IGetSoftwareVersionQueryKey
const ORDER_KEY = "name";
const ORDER_DIRECTION = "asc";
-const convertParamsToSnakeCase = (params: ISoftwareApiParams) => {
- return reduce(
- params,
- (result, val, key) => {
- result[snakeCase(key)] = val;
- return result;
- },
- {}
- );
-};
-
export default {
load: async ({
page,
@@ -104,9 +104,12 @@ export default {
orderDirection: orderDir = ORDER_DIRECTION,
query,
vulnerable,
- availableForInstall,
+ // availableForInstall, // TODO: Is this supported for the versions endpoint?
teamId,
- }: ISoftwareApiParams): Promise => {
+ }: Omit<
+ ISoftwareApiParams,
+ "availableForInstall" | "selfService"
+ >): Promise => {
const { SOFTWARE } = endpoints;
const queryParams = {
page,
@@ -116,7 +119,7 @@ export default {
teamId,
query,
vulnerable,
- availableForInstall,
+ // availableForInstall,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
@@ -197,6 +200,7 @@ export default {
const formData = new FormData();
formData.append("software", data.software);
+ formData.append("self_service", data.selfService.toString());
data.installScript && formData.append("install_script", data.installScript);
data.preInstallCondition &&
formData.append("pre_install_query", data.preInstallCondition);
diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss
index 1032bacc12..74e758e2db 100644
--- a/frontend/styles/var/_global.scss
+++ b/frontend/styles/var/_global.scss
@@ -1,6 +1,7 @@
// border radius
$border-radius: 4px;
-$border-radius-large: 6px;
+$border-radius-medium: 6px;
+$border-radius-large: 8px;
$border-radius-xlarge: 10px;
$border-radius-xxlarge: 16px;
diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss
index 16fa29b45d..df8714f0ce 100644
--- a/frontend/styles/var/mixins.scss
+++ b/frontend/styles/var/mixins.scss
@@ -182,10 +182,11 @@ $max-width: 2560px;
}
}
-@mixin ellipse-text {
+@mixin ellipse-text($width: auto) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ width: $width;
}
@mixin copy-message {
diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts
index 53de0b5498..329d977dba 100644
--- a/frontend/utilities/date_format/index.ts
+++ b/frontend/utilities/date_format/index.ts
@@ -1,10 +1,17 @@
import { formatDistanceToNow } from "date-fns";
-// eslint-disable-next-line import/prefer-default-export
+/** Utility to create a string from a date in this format:
+ `Uploaded .... ago`
+*/
export const uploadedFromNow = (date: string) => {
+ // NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior.
return `Uploaded ${formatDistanceToNow(new Date(date))} ago`;
};
+/** Utility to create a string from a date in this format:
+ `.... ago`
+*/
export const dateAgo = (date: string) => {
+ // NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior.
return `${formatDistanceToNow(new Date(date))} ago`;
};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index b2ddbfd7ae..c1dc7057d9 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -31,6 +31,8 @@ export default {
DEVICE_USER_DETAILS: `/${API_VERSION}/fleet/device`,
DEVICE_SOFTWARE: (token: string) =>
`/${API_VERSION}/fleet/device/${token}/software`,
+ DEVICE_SOFTWARE_INSTALL: (token: string, softwareTitleId: number) =>
+ `/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`,
DEVICE_USER_RESET_ENCRYPTION_KEY: (token: string): string => {
return `/${API_VERSION}/fleet/device/${token}/rotate_encryption_key`;
},
diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx
index 0ebb1f0a97..b84f8c3fa7 100644
--- a/frontend/utilities/helpers.tsx
+++ b/frontend/utilities/helpers.tsx
@@ -24,7 +24,7 @@ import {
} from "date-fns";
import yaml from "js-yaml";
-import { buildQueryStringFromParams } from "utilities/url";
+import { QueryParams, buildQueryStringFromParams } from "utilities/url";
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IPack } from "interfaces/pack";
@@ -828,7 +828,7 @@ interface ILocationParams {
pathPrefix?: string;
routeTemplate?: string;
routeParams?: { [key: string]: string };
- queryParams?: { [key: string]: string | number | undefined };
+ queryParams?: QueryParams;
}
type RouteParams = Record;
diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts
index 5171d25ac1..b8f274ee17 100644
--- a/frontend/utilities/url/index.ts
+++ b/frontend/utilities/url/index.ts
@@ -1,4 +1,4 @@
-import { isEmpty, reduce, omitBy, Dictionary } from "lodash";
+import { isEmpty, reduce, omitBy, Dictionary, snakeCase } from "lodash";
import {
DiskEncryptionStatus,
@@ -250,3 +250,22 @@ export const getLabelParam = (selectedLabels?: string[]) => {
return label.slice(7);
};
+
+type QueryParamish = keyof T extends string
+ ? {
+ [K in keyof T]: QueryValues;
+ }
+ : never;
+
+export const convertParamsToSnakeCase = >(
+ params: T
+) => {
+ return reduce(
+ params,
+ (result, val, key) => {
+ result[snakeCase(key)] = val;
+ return result;
+ },
+ {}
+ );
+};
diff --git a/orbit/changes/18835-add-fleet-desktop-self-service b/orbit/changes/18835-add-fleet-desktop-self-service
new file mode 100644
index 0000000000..c0d54803d1
--- /dev/null
+++ b/orbit/changes/18835-add-fleet-desktop-self-service
@@ -0,0 +1 @@
+* Added the `Self-service` menu item to Fleet Desktop.
diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go
index 89b8e88475..64fbdc4c49 100644
--- a/orbit/cmd/desktop/desktop.go
+++ b/orbit/cmd/desktop/desktop.go
@@ -130,6 +130,10 @@ func main() {
transparencyItem := systray.AddMenuItem("Transparency", "")
transparencyItem.Disable()
+ systray.AddSeparator()
+
+ selfServiceItem := systray.AddMenuItem("Self-service", "")
+ selfServiceItem.Disable()
tokenReader := token.Reader{Path: identifierPath}
if _, err := tokenReader.Read(); err != nil {
@@ -175,6 +179,7 @@ func main() {
myDeviceItem.SetTitle("Connecting...")
myDeviceItem.Disable()
transparencyItem.Disable()
+ selfServiceItem.Disable()
migrateMDMItem.Disable()
migrateMDMItem.Hide()
}
@@ -198,6 +203,7 @@ func main() {
myDeviceItem.SetTitle("My device")
myDeviceItem.Enable()
transparencyItem.Enable()
+ selfServiceItem.Enable()
return
}
@@ -390,6 +396,11 @@ func main() {
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Str("url", openURL).Msg("open browser transparency")
}
+ case <-selfServiceItem.ClickedCh:
+ openURL := client.BrowserSelfServiceURL(tokenReader.GetCached())
+ if err := open.Browser(openURL); err != nil {
+ log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
+ }
case <-migrateMDMItem.ClickedCh:
if err := mdmMigrator.Show(); err != nil {
go reportError(err, nil)
diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go
index 7358ea08b4..90764b32a6 100644
--- a/server/datastore/mysql/activities.go
+++ b/server/datastore/mysql/activities.go
@@ -296,8 +296,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
'host_id', hsi.host_id,
'host_display_name', COALESCE(hdn.display_name, ''),
'software_title', COALESCE(st.name, ''),
+ 'software_package', si.filename,
'install_uuid', hsi.execution_id,
- 'status', %s
+ 'status', CAST(%s AS CHAR),
+ 'self_service', si.self_service IS TRUE
) as details
FROM
host_software_installs hsi
@@ -333,9 +335,9 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
"ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(),
"installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(),
"max_wait_time": seconds,
- "software_status_failed": fleet.SoftwareInstallerFailed,
- "software_status_installed": fleet.SoftwareInstallerInstalled,
- "software_status_pending": fleet.SoftwareInstallerPending,
+ "software_status_failed": string(fleet.SoftwareInstallerFailed),
+ "software_status_installed": string(fleet.SoftwareInstallerInstalled),
+ "software_status_pending": string(fleet.SoftwareInstallerPending),
})
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args")
diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go
index 1df159b297..aad9419485 100644
--- a/server/datastore/mysql/activities_test.go
+++ b/server/datastore/mysql/activities_test.go
@@ -431,9 +431,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
require.NoError(t, err)
h1E := hsr.ExecutionID
// create some software installs requests for h1, make some complete
- h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID)
+ h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false)
require.NoError(t, err)
- h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID)
+ h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
@@ -441,7 +441,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
PreInstallConditionOutput: ptr.String(""), // pre-install failed
})
require.NoError(t, err)
- h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID)
+ h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
@@ -450,7 +450,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
InstallScriptExitCode: ptr.Int(0),
})
require.NoError(t, err)
- h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID) // no user for this one
+ h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one
require.NoError(t, err)
// create a single pending request for h2, as well as a non-pending one
@@ -463,7 +463,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
require.NoError(t, err)
h2F := hsr.ExecutionID
// add a pending software install request for h2
- h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID)
+ h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false)
require.NoError(t, err)
// nothing for h3
diff --git a/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go
new file mode 100644
index 0000000000..1d27717752
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go
@@ -0,0 +1,28 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20240521143024, Down_20240521143024)
+}
+
+func Up_20240521143024(tx *sql.Tx) error {
+ _, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN self_service bool NOT NULL DEFAULT false`)
+ if err != nil {
+ return fmt.Errorf("failed to add self_service to software_installers: %w", err)
+ }
+
+ _, err = tx.Exec(`ALTER TABLE host_software_installs ADD COLUMN self_service bool NOT NULL DEFAULT false`)
+ if err != nil {
+ return fmt.Errorf("failed to add self_service bool to host_software_installs: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20240521143024(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go
new file mode 100644
index 0000000000..e39f899290
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go
@@ -0,0 +1,63 @@
+package tables
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20240521143024(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ //
+ // Insert data to test the migration
+ //
+ // ...
+
+ script1 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo hi', 'a')")
+ script2 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo bye', 'b')")
+
+ software := execNoErrLastID(t, db, `
+INSERT INTO software_installers (
+ filename,
+ version,
+ platform,
+ install_script_content_id,
+ post_install_script_content_id,
+ storage_id
+) VALUES (
+ 'fleet',
+ '1.0.0',
+ 'windows',
+ ?,
+ ?,
+ 'a'
+)`, script1, script2)
+
+ host := insertHost(t, db, nil)
+
+ install := execNoErrLastID(t, db, `
+INSERT INTO host_software_installs (
+ host_id,
+ execution_id,
+ software_installer_id
+) VALUES (?, ?, ?)`, host, "e", software)
+
+ // Apply current migration.
+ applyNext(t, db)
+
+ //
+ // Check data, insert new entries, e.g. to verify migration is safe.
+ //
+ // ...
+
+ var self_service bool
+ err := db.Get(&self_service, "SELECT self_service FROM software_installers WHERE id = ?", software)
+ require.NoError(t, err)
+ require.False(t, self_service)
+
+ var host_self_service bool
+ err = db.Get(&host_self_service, "SELECT self_service FROM host_software_installs WHERE id = ?", install)
+ require.NoError(t, err)
+ require.False(t, host_self_service)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index 01136815c4..0d76506734 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -499,6 +499,7 @@ CREATE TABLE `host_software_installs` (
`user_id` int(10) unsigned DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `self_service` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_host_software_installs_execution_id` (`execution_id`),
KEY `fk_host_software_installs_installer_id` (`software_installer_id`),
@@ -924,9 +925,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=268 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mobile_device_management_solutions` (
@@ -1527,6 +1528,7 @@ CREATE TABLE `software_installers` (
`post_install_script_content_id` int(10) unsigned DEFAULT NULL,
`storage_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `self_service` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`),
KEY `fk_software_installers_title` (`title_id`),
diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go
index b4db0c72cb..4856e1b831 100644
--- a/server/datastore/mysql/software.go
+++ b/server/datastore/mysql/software.go
@@ -1825,6 +1825,9 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string {
if colAlias != "" {
colAlias = " AS " + colAlias
}
+ // the computed column assumes that all results (pre, install and post) are
+ // stored at once, so that if there is an exit code for the install script
+ // and none for the post-install, it is because there is no post-install.
return fmt.Sprintf(`
CASE
WHEN %[1]spost_install_script_exit_code IS NOT NULL AND
@@ -1848,17 +1851,19 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string {
END %[2]s `, tblAlias, colAlias)
}
-func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
- // `status` computed column assumes that all results (pre, install and post)
- // are stored at once, so that if there is an exit code for the install
- // script and none for the post-install, it is because there is no
- // post-install.
+func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
+ var onlySelfServiceClause string
+ if opts.SelfServiceOnly {
+ onlySelfServiceClause = ` AND si.self_service = 1 `
+ }
stmtInstalled := fmt.Sprintf(`
SELECT
st.id,
st.name,
st.source,
+ si.self_service as self_service,
si.filename as package_available_for_install,
+ si.version as package_version,
hsi.created_at as last_install_installed_at,
hsi.execution_id as last_install_install_uuid,
%s,
@@ -1890,14 +1895,17 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
) OR
-- or software install has been attempted on host
hsi.host_id IS NOT NULL )
-`, softwareInstallerHostStatusNamedQuery("hsi", "status"))
+ %s
+`, softwareInstallerHostStatusNamedQuery("hsi", "status"), onlySelfServiceClause)
const stmtAvailable = `
SELECT
st.id,
st.name,
st.source,
+ si.self_service as self_service,
si.filename as package_available_for_install,
+ si.version as package_version,
NULL as last_install_installed_at,
NULL as last_install_install_uuid,
NULL as status,
@@ -1929,6 +1937,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
hsi.software_installer_id = si.id
) AND
si.global_or_team_id = (SELECT COALESCE(h.team_id, 0) FROM hosts h WHERE h.id = ?)
+ %s
`
const selectColNames = `
@@ -1936,7 +1945,9 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
id,
name,
source,
+ self_service,
package_available_for_install,
+ package_version,
last_install_installed_at,
last_install_install_uuid,
status
@@ -1954,7 +1965,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
return nil, nil, ctxerr.Wrap(ctx, err, "build named query for list host software")
}
- if includeAvailableForInstall {
+ if opts.IncludeAvailableForInstall {
platformArgs := []string{host.Platform}
if fleet.IsLinux(host.Platform) {
platformArgs = fleet.HostLinuxOSs
@@ -1964,20 +1975,20 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
placeholders += "?,"
args = append(args, p)
}
- stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ","))
+ stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ","), onlySelfServiceClause)
args = append(args, host.ID, host.ID, host.ID)
}
stmt = selectColNames + ` FROM ( ` + stmt + ` ) AS tbl `
- if opts.MatchQuery != "" {
+ if opts.ListOptions.MatchQuery != "" {
stmt += " WHERE TRUE " // searchLike adds a "AND "
- stmt, args = searchLike(stmt, args, opts.MatchQuery, "name")
+ stmt, args = searchLike(stmt, args, opts.ListOptions.MatchQuery, "name")
}
// build the count statement before adding pagination constraints
countStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, stmt)
- stmt, _ = appendListOptionsToSQL(stmt, &opts)
+ stmt, _ = appendListOptionsToSQL(stmt, &opts.ListOptions)
// perform a second query to grab the titleCount
var titleCount uint
@@ -1990,6 +2001,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
LastInstallInstalledAt *time.Time `db:"last_install_installed_at"`
LastInstallInstallUUID *string `db:"last_install_install_uuid"`
StatusSort sql.NullInt32 `db:"status_sort"`
+ PackageVersion *string `db:"package_version"`
}
var hostSoftwareList []*hostSoftware
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil {
@@ -2010,6 +2022,21 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
hs.LastInstall.InstalledAt = *hs.LastInstallInstalledAt
}
}
+
+ // promote the package name and version to the proper destination fields
+ // (the service layer will arbitrate whether package_available_for_install
+ // or package fields are returned).
+ if hs.PackageAvailableForInstall != nil {
+ var version string
+ if hs.PackageVersion != nil {
+ version = *hs.PackageVersion
+ }
+ hs.Package = &fleet.DeviceSoftwarePackage{
+ Name: *hs.PackageAvailableForInstall,
+ Version: version,
+ }
+ }
+
titleIDs = append(titleIDs, hs.ID)
byTitleID[hs.ID] = hs
}
@@ -2115,14 +2142,14 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc
}
}
- perPage := opts.PerPage
+ perPage := opts.ListOptions.PerPage
var metaData *fleet.PaginationMetadata
- if opts.IncludeMetadata {
+ if opts.ListOptions.IncludeMetadata {
if perPage <= 0 {
perPage = defaultSelectLimit
}
metaData = &fleet.PaginationMetadata{
- HasPreviousResults: opts.Page > 0,
+ HasPreviousResults: opts.ListOptions.Page > 0,
TotalResults: titleCount,
}
if len(hostSoftwareList) > int(perPage) {
diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go
index 40e88ecdad..e4aaaaf765 100644
--- a/server/datastore/mysql/software_installers.go
+++ b/server/datastore/mysql/software_installers.go
@@ -41,6 +41,7 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId
hsi.host_id AS host_id,
hsi.execution_id AS execution_id,
hsi.software_installer_id AS installer_id,
+ hsi.self_service AS self_service,
COALESCE(si.pre_install_query, '') AS pre_install_condition,
inst.contents AS install_script,
COALESCE(pisnt.contents, '') AS post_install_script
@@ -104,8 +105,9 @@ INSERT INTO software_installers (
install_script_content_id,
pre_install_query,
post_install_script_content_id,
- platform
-) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ platform,
+ self_service
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args := []interface{}{
payload.TeamID,
@@ -118,6 +120,7 @@ INSERT INTO software_installers (
payload.PreInstallQuery,
postInstallScriptID,
payload.Platform,
+ payload.SelfService,
}
res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...)
@@ -204,6 +207,7 @@ SELECT
si.pre_install_query,
si.post_install_script_content_id,
si.uploaded_at,
+ si.self_service,
COALESCE(st.name, '') AS software_title
%s
FROM
@@ -245,13 +249,13 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error
return nil
}
-func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint) (string, error) {
+func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) {
const (
insertStmt = `
INSERT INTO host_software_installs
- (execution_id, host_id, software_installer_id, user_id)
+ (execution_id, host_id, software_installer_id, user_id, self_service)
VALUES
- (?, ?, ?, ?)
+ (?, ?, ?, ?, ?)
`
hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?`
@@ -278,6 +282,7 @@ func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID ui
hostID,
softwareInstallerID,
userID,
+ selfService,
)
return installID, ctxerr.Wrap(ctx, err, "inserting new install software request")
@@ -299,7 +304,8 @@ SELECT
h.team_id AS host_team_id,
hsi.user_id AS user_id,
hsi.post_install_script_exit_code,
- hsi.install_script_exit_code
+ hsi.install_script_exit_code,
+ hsi.self_service
FROM
host_software_installs hsi
JOIN hosts h ON h.id = hsi.host_id
diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go
index 4fd6e7e5d5..8542002b69 100644
--- a/server/datastore/mysql/software_installers_test.go
+++ b/server/datastore/mysql/software_installers_test.go
@@ -71,16 +71,30 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID1)
+ installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "banana",
+ PreInstallQuery: "SELECT 3",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage3",
+ Filename: "file3",
+ Title: "file3",
+ Version: "3.0",
+ Source: "apps",
+ SelfService: true,
+ })
require.NoError(t, err)
- hostInstall2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID2)
+ hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID1, false)
require.NoError(t, err)
- hostInstall3, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID1)
+ hostInstall2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID2, false)
require.NoError(t, err)
- hostInstall4, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2)
+ hostInstall3, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID1, false)
+ require.NoError(t, err)
+
+ hostInstall4, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
@@ -90,7 +104,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- hostInstall5, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2)
+ hostInstall5, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID2, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
@@ -122,6 +136,29 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
require.Equal(t, "world", exec1.PostInstallScript)
require.Equal(t, installerID1, exec1.InstallerID)
require.Equal(t, "SELECT 1", exec1.PreInstallCondition)
+ require.False(t, exec1.SelfService)
+
+ hostInstall6, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID3, true)
+ require.NoError(t, err)
+
+ err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
+ HostID: host1.ID,
+ InstallUUID: hostInstall6,
+ PreInstallConditionOutput: ptr.String("output"),
+ })
+ require.NoError(t, err)
+
+ exec2, err := ds.GetSoftwareInstallDetails(ctx, hostInstall6)
+ require.NoError(t, err)
+
+ require.Equal(t, host1.ID, exec2.HostID)
+ require.Equal(t, hostInstall6, exec2.ExecutionID)
+ require.Equal(t, "banana", exec2.InstallScript)
+ require.Equal(t, "apple", exec2.PostInstallScript)
+ require.Equal(t, installerID3, exec2.InstallerID)
+ require.Equal(t, "SELECT 3", exec2.PreInstallCondition)
+ require.True(t, exec2.SelfService)
+
}
func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
@@ -161,7 +198,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
require.Equal(t, "foo.pkg", si.Name)
// non-existent host
- _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID)
+ _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, false)
require.ErrorAs(t, err, &nfe)
// successful insert
@@ -174,7 +211,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
TeamID: teamID,
})
require.NoError(t, err)
- _, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID)
+ _, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID, false)
require.NoError(t, err)
// list hosts with software install requests
@@ -270,7 +307,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID)
+ installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go
index 163ed5a724..01327ea037 100644
--- a/server/datastore/mysql/software_test.go
+++ b/server/datastore/mysql/software_test.go
@@ -3045,20 +3045,35 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("linux"))
otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
- opts := fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}
+ opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus {
return &s
}
// no software yet
- sw, meta, err := ds.ListHostSoftware(ctx, host, false, opts)
+ sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// works with available software too
- sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
+ require.NoError(t, err)
+ require.Empty(t, sw)
+ require.Equal(t, &fleet.PaginationMetadata{}, meta)
+
+ // self-service only works too
+ opts.SelfServiceOnly = true
+ opts.IncludeAvailableForInstall = true
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
+ require.NoError(t, err)
+ require.Empty(t, sw)
+ require.Equal(t, &fleet.PaginationMetadata{}, meta)
+
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
@@ -3175,6 +3190,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
require.True(t, ok)
require.Equal(t, e.Name, g.Name)
require.Equal(t, e.Source, g.Source)
+ if e.SelfService != nil {
+ // there is a software installer, so package information should be present
+ require.Equal(t, e.SelfService, g.SelfService)
+ require.NotNil(t, g.Package)
+ require.NotNil(t, g.PackageAvailableForInstall)
+ require.Equal(t, e.PackageAvailableForInstall, g.PackageAvailableForInstall)
+ require.Equal(t, *e.PackageAvailableForInstall, g.Package.Name)
+ require.NotEmpty(t, g.Package.Version)
+ }
require.Len(t, g.InstalledVersions, len(e.InstalledVersions))
if len(e.InstalledVersions) > 0 {
byVers := make(map[string]fleet.HostSoftwareInstalledVersion, len(e.InstalledVersions))
@@ -3201,7 +3225,9 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
// it now returns the software with vulnerabilities and installed paths
- sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.SelfServiceOnly = false
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta)
compareResults(expected, sw, true)
@@ -3258,16 +3284,17 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
res, err := q.ExecContext(ctx, `
INSERT INTO software_installers
- (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform)
+ (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform, self_service)
VALUES
- (?, ?, ?, ?, ?, ?, unhex(?), ?)`,
- teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux")
+ (?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`,
+ teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux", i < 2)
if err != nil {
return err
}
id, _ := res.LastInsertId()
swiIDs = append(swiIDs, uint(id))
}
+ // sw1Pending and swi2Installed are self-service installers
swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4]
// create the results for the host
@@ -3341,6 +3368,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Status: expectStatus(fleet.SoftwareInstallerPending),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"},
PackageAvailableForInstall: ptr.String("installer-0.pkg"),
+ SelfService: ptr.Bool(true),
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
},
@@ -3350,6 +3378,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerInstalled),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"},
+ SelfService: ptr.Bool(true),
PackageAvailableForInstall: ptr.String("installer-1.pkg"),
}
expected[i0.Name+i0.Source] = i0
@@ -3359,12 +3388,14 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerFailed),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"},
+ SelfService: ptr.Bool(false),
PackageAvailableForInstall: ptr.String("installer-2.pkg"),
}
expected[i1.Name+i1.Source] = i1
// request without available software
- sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
compareResults(expected, sw, true)
@@ -3376,6 +3407,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Status: nil,
LastInstall: nil,
PackageAvailableForInstall: ptr.String("installer-3.pkg"),
+ SelfService: ptr.Bool(false),
}
expected[i2.Name+i2.Source] = i2
@@ -3385,23 +3417,26 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Status: nil,
LastInstall: nil,
PackageAvailableForInstall: ptr.String("installer-4.pkg"),
+ SelfService: ptr.Bool(false),
}
expected[i3.Name+i3.Source] = i3
- sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
// request in descending order
- opts.OrderDirection = fleet.OrderDescending
- opts.TestSecondaryOrderDirection = fleet.OrderDescending
- sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.ListOptions.OrderDirection = fleet.OrderDescending
+ opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderDescending
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
compareResults(expected, sw, false, i2.Name+i2.Source, i3.Name+i3.Source)
- opts.OrderDirection = fleet.OrderAscending
- opts.TestSecondaryOrderDirection = fleet.OrderAscending
+ opts.ListOptions.OrderDirection = fleet.OrderAscending
+ opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending
// record a new install request for i1, this time as pending, and mark install request for b (swi1) as failed
time.Sleep(time.Second) // ensure the timestamp is later
@@ -3430,6 +3465,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Status: expectStatus(fleet.SoftwareInstallerFailed),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"},
PackageAvailableForInstall: ptr.String("installer-0.pkg"),
+ SelfService: ptr.Bool(true),
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
},
@@ -3439,17 +3475,20 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"},
+ SelfService: ptr.Bool(false),
PackageAvailableForInstall: ptr.String("installer-2.pkg"),
}
// request without available software
- sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
compareResults(expected, sw, true, i2.Name+i2.Source, i3.Name+i3.Source)
// request with available software)
- sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
@@ -3460,13 +3499,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// no installed software for this host
- sw, meta, err = ds.ListHostSoftware(ctx, tmHost, false, opts)
+ opts.IncludeAvailableForInstall = false
+ sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// sees the available installer in its team
- sw, meta, err = ds.ListHostSoftware(ctx, tmHost, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
@@ -3474,86 +3515,92 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}, sw, true)
// test with a search query (searches on name), with and without available software
- opts.MatchQuery = "a"
- sw, _, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.ListOptions.MatchQuery = "a"
+ opts.IncludeAvailableForInstall = false
+ sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source],
byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source],
}, sw, true)
- sw, _, err = ds.ListHostSoftware(ctx, host, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source],
byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source],
}, sw, true)
- opts.MatchQuery = "zz"
- sw, _, err = ds.ListHostSoftware(ctx, host, false, opts)
+ opts.ListOptions.MatchQuery = "zz"
+ opts.IncludeAvailableForInstall = false
+ sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
- sw, _, err = ds.ListHostSoftware(ctx, host, true, opts)
+ opts.IncludeAvailableForInstall = true
+ sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
// test the pagination
cases := []struct {
- opts fleet.ListOptions
- withAvailable bool
- wantNames []string
- wantMeta *fleet.PaginationMetadata
+ opts fleet.HostSoftwareTitleListOptions
+ wantNames []string
+ wantMeta *fleet.PaginationMetadata
}{
{
- opts: fleet.ListOptions{PerPage: 3},
- withAvailable: false,
- wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: false},
+ wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7},
},
{
- opts: fleet.ListOptions{Page: 1, PerPage: 3},
- withAvailable: false,
- wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: false},
+ wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7},
},
{
- opts: fleet.ListOptions{Page: 2, PerPage: 3},
- withAvailable: false,
- wantNames: []string{i1.Name},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 3}, IncludeAvailableForInstall: false},
+ wantNames: []string{i1.Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7},
},
{
- opts: fleet.ListOptions{Page: 3, PerPage: 3},
- withAvailable: false,
- wantNames: []string{},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3}, IncludeAvailableForInstall: false},
+ wantNames: []string{},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7},
},
{
- opts: fleet.ListOptions{PerPage: 4},
- withAvailable: true,
- wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 4}, IncludeAvailableForInstall: true},
+ wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8},
},
{
- opts: fleet.ListOptions{Page: 1, PerPage: 4},
- withAvailable: true,
- wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, IncludeAvailableForInstall: true},
+ wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
},
{
- opts: fleet.ListOptions{Page: 2, PerPage: 4},
- withAvailable: true,
- wantNames: []string{},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, IncludeAvailableForInstall: true},
+ wantNames: []string{},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
+ },
+ {
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true},
+ wantNames: []string{byNSV[b].Name, i0.Name},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2},
+ },
+ {
+ opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true},
+ wantNames: []string{},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 2},
},
}
for _, c := range cases {
- t.Run(fmt.Sprintf("%t: %#v", c.withAvailable, c.opts), func(t *testing.T) {
+ t.Run(fmt.Sprintf("%#v", c.opts), func(t *testing.T) {
// always include metadata
- c.opts.IncludeMetadata = true
- c.opts.OrderKey = "name"
- c.opts.TestSecondaryOrderKey = "source"
+ c.opts.ListOptions.IncludeMetadata = true
+ c.opts.ListOptions.OrderKey = "name"
+ c.opts.ListOptions.TestSecondaryOrderKey = "source"
- sw, meta, err := ds.ListHostSoftware(ctx, host, c.withAvailable, c.opts)
+ sw, meta, err := ds.ListHostSoftware(ctx, host, c.opts)
require.NoError(t, err)
require.Equal(t, len(c.wantNames), len(sw))
diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go
index a74cf98c18..2364614534 100644
--- a/server/datastore/mysql/software_titles.go
+++ b/server/datastore/mysql/software_titles.go
@@ -35,7 +35,7 @@ SELECT
MAX(sthc.updated_at) as counts_updated_at
FROM software_titles st
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND %s
-WHERE st.id = ?
+WHERE st.id = ?
AND (sthc.hosts_count > 0 OR EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id AND si.global_or_team_id = ?))
GROUP BY
st.id,
@@ -202,9 +202,10 @@ SELECT
st.browser,
MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count,
MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at,
- si.filename as software_package
+ si.filename as software_package,
+ COALESCE(si.self_service, false) as self_service
FROM software_titles st
-LEFT JOIN software_installers si ON si.title_id = st.id AND COALESCE(si.team_id, 0) = ?
+LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ?
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ?
-- placeholder for JOIN on software/software_cve
%s
@@ -212,19 +213,16 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND
WHERE %s
-- placeholder for filter based on software installed on hosts + software installers
AND (%s)
-GROUP BY st.id, software_package`
+GROUP BY st.id, software_package, self_service`
cveJoinType := "LEFT"
if opt.VulnerableOnly {
cveJoinType = "INNER"
}
- var globalOrTeamID uint
args := []any{0, 0}
if opt.TeamID != nil {
- args[0] = *opt.TeamID
- args[1] = *opt.TeamID
- globalOrTeamID = *opt.TeamID
+ args[0], args[1] = *opt.TeamID, *opt.TeamID
}
additionalWhere := "TRUE"
@@ -248,15 +246,9 @@ GROUP BY st.id, software_package`
args = append(args, match, match)
}
+ // default to "a software installer exists", and see next condition.
defaultFilter := `
- EXISTS (
- SELECT 1
- FROM
- software_installers si
- WHERE
- si.title_id = st.id
- AND si.global_or_team_id = ?
- )
+ si.id IS NOT NULL
`
// add software installed for hosts if any of this is true:
@@ -264,10 +256,11 @@ GROUP BY st.id, software_package`
// - we're not filtering for "available for install" only
// - we're filtering by vulnerable only
if !opt.AvailableForInstall || opt.VulnerableOnly {
- defaultFilter += `OR sthc.hosts_count > 0`
+ defaultFilter = ` ( ` + defaultFilter + ` OR sthc.hosts_count > 0 ) `
+ }
+ if opt.SelfServiceOnly {
+ defaultFilter += ` AND si.self_service = 1 `
}
-
- args = append(args, globalOrTeamID)
stmt = fmt.Sprintf(stmt, softwareJoin, additionalWhere, defaultFilter)
return stmt, args
diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go
index ea70177055..cb837cb0a5 100644
--- a/server/datastore/mysql/software_titles_test.go
+++ b/server/datastore/mysql/software_titles_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -297,6 +298,11 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
require.NotZero(t, installer1)
+ // make installer1 "self-service" available
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1)
+ return err
+ })
// create a software installer with an install request on host1
installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
@@ -305,7 +311,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
Filename: "installer2.pkg",
})
require.NoError(t, err)
- _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2)
+ _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
@@ -455,6 +461,16 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
require.Equal(t, "apps", titles[0].Source)
require.Equal(t, "installer1", titles[1].Name)
require.Equal(t, "apps", titles[1].Source)
+
+ // filter on self-service only
+ titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{
+ OrderKey: "name",
+ OrderDirection: fleet.OrderDescending,
+ }, SelfServiceOnly: true}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
+ require.NoError(t, err)
+ require.Len(t, titles, 1)
+ require.Equal(t, "installer1", titles[0].Name)
+ require.Equal(t, "apps", titles[0].Source)
}
func listSoftwareTitlesCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareTitleListOptions) []fleet.SoftwareTitleListResult {
@@ -509,6 +525,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
require.NotZero(t, installer1)
+ // make installer1 "self-service" available
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1)
+ return err
+ })
// create a software installer for team2
installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
@@ -605,6 +626,24 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
require.Equal(t, uint(1), titles[0].VersionsCount)
require.Equal(t, uint(1), titles[1].VersionsCount)
require.Equal(t, uint(0), titles[2].VersionsCount)
+
+ // Testing the team 1 user with self-service only
+ titles, _, _, err = ds.ListSoftwareTitles(
+ context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team1.ID}, team1TeamFilter,
+ )
+ // installer1 is associated with team 1
+ require.NoError(t, err)
+ require.Len(t, titles, 1)
+ require.Equal(t, "installer1", titles[0].Name)
+ require.Equal(t, "apps", titles[0].Source)
+
+ // Testing the team 2 user with self-service only
+ titles, _, _, err = ds.ListSoftwareTitles(context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team2.ID}, fleet.TeamFilter{
+ User: userTeam2Admin,
+ IncludeObserver: true,
+ })
+ require.NoError(t, err)
+ require.Len(t, titles, 0)
}
func sortTitlesByName(titles []fleet.SoftwareTitleListResult) {
diff --git a/server/fleet/activities.go b/server/fleet/activities.go
index cfd15960c8..c3d2f3c0f0 100644
--- a/server/fleet/activities.go
+++ b/server/fleet/activities.go
@@ -1427,6 +1427,8 @@ type ActivityTypeInstalledSoftware struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
SoftwareTitle string `json:"software_title"`
+ SoftwarePackage string `json:"software_package"`
+ SelfService bool `json:"self_service"`
InstallUUID string `json:"install_uuid"`
Status string `json:"status"`
}
@@ -1445,11 +1447,15 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
- "install_uuid": ID of the software installation.
+- "self_service": Whether the installation was initiated by the end user.
- "software_title": Name of the software.
+- "software_package": Filename of the installer.
- "status": Status of the software installation.`, `{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Falcon.app",
+ "software_package": "FalconSensor-6.44.pkg",
+ "self_service": true,
"install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"status": "pending"
}`
@@ -1460,6 +1466,7 @@ type ActivityTypeAddedSoftware struct {
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
+ SelfService bool `json:"self_service"`
}
func (a ActivityTypeAddedSoftware) ActivityName() string {
@@ -1471,14 +1478,14 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) {
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team." +
-- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.`,
- `{
+- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
+- "self_service": Whether the software is available for installation by the end user.`, `{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
- "team_id": 123
-}
-`
+ "team_id": 123,
+ "self_service": true
+}`
}
type ActivityTypeDeletedSoftware struct {
@@ -1486,6 +1493,7 @@ type ActivityTypeDeletedSoftware struct {
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
+ SelfService bool `json:"self_service"`
}
func (a ActivityTypeDeletedSoftware) ActivityName() string {
@@ -1497,14 +1505,14 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) {
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added.` + " `null " + `if it was added to no team.
-- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.`,
- `{
+- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
+- "self_service": Whether the software was available for installation by the end user.`, `{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
- "team_id": 123
-}
-`
+ "team_id": 123,
+ "self_service": true
+}`
}
// LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams.
diff --git a/server/fleet/app.go b/server/fleet/app.go
index 2b378be1f5..4c10781c60 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -1261,7 +1261,8 @@ type KafkaRESTConfig struct {
// DeviceGlobalConfig is a subset of AppConfig with information used by the
// device endpoints
type DeviceGlobalConfig struct {
- MDM DeviceGlobalMDMConfig `json:"mdm"`
+ MDM DeviceGlobalMDMConfig `json:"mdm"`
+ Features DeviceFeatures `json:"features"`
}
// DeviceGlobalMDMConfig is a subset of AppConfig.MDM with information used by
@@ -1270,6 +1271,14 @@ type DeviceGlobalMDMConfig struct {
EnabledAndConfigured bool `json:"enabled_and_configured"`
}
+// DeviceFeatures is a subset of AppConfig.Features with information used by
+// the device endpoints.
+type DeviceFeatures struct {
+ // EnableSoftwareInventory is the setting used by the device's team (or
+ // globally in the AppConfig if the device is not in any team).
+ EnableSoftwareInventory bool `json:"enable_software_inventory"`
+}
+
// Version is the authz type used to check access control to the version endpoint.
type Version struct{}
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 4dd68620cc..89d129a439 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -499,7 +499,7 @@ type Datastore interface {
// InsertSoftwareInstallRequest tracks a new request to install the provided
// software installer in the host. It returns the auto-generated installation
// uuid.
- InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint) (string, error)
+ InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error)
///////////////////////////////////////////////////////////////////////////////
// SoftwareStore
@@ -555,7 +555,7 @@ type Datastore interface {
InsertCVEMeta(ctx context.Context, cveMeta []CVEMeta) error
ListCVEs(ctx context.Context, maxAge time.Duration) ([]CVEMeta, error)
- ListHostSoftware(ctx context.Context, host *Host, includeAvailableForInstall bool, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
+ ListHostSoftware(ctx context.Context, host *Host, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
// SetHostSoftwareInstallResult records the result of a software installation
// attempt on the host.
diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go
index 70ccb510d3..6f803f28c1 100644
--- a/server/fleet/scripts.go
+++ b/server/fleet/scripts.go
@@ -363,6 +363,7 @@ type SoftwareInstallerPayload struct {
PreInstallQuery string `json:"pre_install_query"`
InstallScript string `json:"install_script"`
PostInstallScript string `json:"post_install_script"`
+ SelfService bool `json:"self_service"`
}
type HostLockWipeStatus struct {
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 01d5a6bc55..732202b139 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -415,7 +415,7 @@ type Service interface {
// ListHostSoftware lists the software installed or available for install on
// the specified host.
- ListHostSoftware(ctx context.Context, hostID uint, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
+ ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
// /////////////////////////////////////////////////////////////////////////////
// AppConfigService provides methods for configuring the Fleet application
@@ -642,6 +642,10 @@ type Service interface {
// specified team
BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) error
+ // SelfServiceInstallSoftwareTitle installs a software title
+ // initiated by the user
+ SelfServiceInstallSoftwareTitle(ctx context.Context, host *Host, softwareTitleID uint) error
+
// /////////////////////////////////////////////////////////////////////////////
// Vulnerabilities
diff --git a/server/fleet/software.go b/server/fleet/software.go
index 0842d74933..6cab811cc4 100644
--- a/server/fleet/software.go
+++ b/server/fleet/software.go
@@ -184,6 +184,8 @@ type SoftwareTitleListResult struct {
CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"`
// SoftwarePackage is the filename of the installer for this software title.
SoftwarePackage *string `json:"software_package" db:"software_package"`
+ // SelfService indicates if the end user can initiate the installation
+ SelfService bool `json:"self_service" db:"self_service"`
}
type SoftwareTitleListOptions struct {
@@ -193,6 +195,22 @@ type SoftwareTitleListOptions struct {
TeamID *uint `query:"team_id,optional"`
VulnerableOnly bool `query:"vulnerable,optional"`
AvailableForInstall bool `query:"available_for_install,optional"`
+ SelfServiceOnly bool `query:"self_service,optional"`
+}
+
+type HostSoftwareTitleListOptions struct {
+ // ListOptions cannot be embedded in order to unmarshal with validation.
+ ListOptions ListOptions `url:"list_options"`
+
+ // SelfServiceOnly limits the returned software titles to those that are
+ // available to install by the end user via the self-service. Implies
+ // AvailableForInstall.
+ SelfServiceOnly bool `query:"self_service,optional"`
+
+ // IncludeAvailableForInstall is not a query argument, it is set in the
+ // service layer to indicate to the datastore if software available for
+ // install (but not currently installed on the host) should be returned.
+ IncludeAvailableForInstall bool
}
// AuthzSoftwareInventory is used for access controls on software inventory.
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index 068e539fc6..ee7f544d15 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -61,6 +61,8 @@ type SoftwareInstallDetails struct {
InstallScript string `json:"install_script" db:"install_script"`
// PostInstallScript is the script to run after installing the software package.
PostInstallScript string `json:"post_install_script" db:"post_install_script"`
+ // SelfService indicates the install was initiated by the device user
+ SelfService bool `json:"self_service" db:"self_service"`
}
// SoftwareInstaller represents a software installer package that can be used to install software on
@@ -95,6 +97,9 @@ type SoftwareInstaller struct {
Status *SoftwareInstallerStatusSummary `json:"status,omitempty" db:"-"`
// SoftwareTitle is the title of the software pointed installed by this installer.
SoftwareTitle string `json:"-" db:"software_title"`
+ // SelfService indicates that the software can be installed by the
+ // end user without admin intervention
+ SelfService bool `json:"self_service" db:"self_service"`
}
// AuthzType implements authz.AuthzTyper.
@@ -175,6 +180,9 @@ type HostSoftwareInstallerResult struct {
InstallScriptExitCode *int `json:"-" db:"install_script_exit_code"`
// PostInstallScriptExitCode is used internally to determine the output displayed to the user.
PostInstallScriptExitCode *int `json:"-" db:"post_install_script_exit_code"`
+ // SelfService indicates that the installation was queued by the
+ // end user and not an administrator
+ SelfService bool `json:"self_service" db:"self_service"`
}
const (
@@ -252,6 +260,7 @@ type UploadSoftwareInstallerPayload struct {
Version string
Source string
Platform string
+ SelfService bool
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
@@ -296,14 +305,26 @@ type HostSoftwareWithInstaller struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"`
+ SelfService *bool `json:"self_service,omitempty" db:"self_service"`
Status *SoftwareInstallerStatus `json:"status" db:"status"`
LastInstall *HostSoftwareInstall `json:"last_install"`
InstalledVersions []*HostSoftwareInstalledVersion `json:"installed_versions"`
// PackageAvailableForInstall is only present for the user-authenticated
- // endpoint, not the device-authenticated one. I.e. when
- // available-but-not-installed software are part of the response.
+ // endpoint, not the device-authenticated one.
PackageAvailableForInstall *string `json:"package_available_for_install,omitempty" db:"package_available_for_install"`
+
+ // Package provides software installer package information, it is only
+ // present for the device-authenticated endpoint, not for the
+ // user-authenticated one.
+ Package *DeviceSoftwarePackage `json:"package,omitempty"`
+}
+
+// DeviceSoftwarePackage provides information about a software installer
+// package for self-service on a device.
+type DeviceSoftwarePackage struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
}
// HostSoftwareInstall represents installation of software on a host from a
diff --git a/server/fleet/teams.go b/server/fleet/teams.go
index d7c41f1d8b..984d295343 100644
--- a/server/fleet/teams.go
+++ b/server/fleet/teams.go
@@ -163,6 +163,7 @@ type TeamSpecSoftwareAsset struct {
type TeamSpecSoftware struct {
URL string `json:"url"`
+ SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 02c4a3ce40..32b3a98910 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -369,7 +369,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis
type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error)
-type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint) (string, error)
+type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error)
type ListSoftwareForVulnDetectionFunc func(ctx context.Context, hostID uint) ([]fleet.Software, error)
@@ -405,7 +405,7 @@ type InsertCVEMetaFunc func(ctx context.Context, cveMeta []fleet.CVEMeta) error
type ListCVEsFunc func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error)
-type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error)
+type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error)
type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error
@@ -3613,11 +3613,11 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter)
}
-func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint) (string, error) {
+func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) {
s.mu.Lock()
s.InsertSoftwareInstallRequestFuncInvoked = true
s.mu.Unlock()
- return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID)
+ return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService)
}
func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, hostID uint) ([]fleet.Software, error) {
@@ -3739,11 +3739,11 @@ func (s *DataStore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]fleet
return s.ListCVEsFunc(ctx, maxAge)
}
-func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
+func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostSoftwareFuncInvoked = true
s.mu.Unlock()
- return s.ListHostSoftwareFunc(ctx, host, includeAvailableForInstall, opts)
+ return s.ListHostSoftwareFunc(ctx, host, opts)
}
func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error {
diff --git a/server/service/client.go b/server/service/client.go
index 53fb01a653..69a1537f0d 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -616,6 +616,7 @@ func (c *Client) ApplyGroup(
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: si.URL,
+ SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
PostInstallScript: string(pc),
diff --git a/server/service/device_client.go b/server/service/device_client.go
index 8d89378ee4..64e3c36c58 100644
--- a/server/service/device_client.go
+++ b/server/service/device_client.go
@@ -125,6 +125,15 @@ func (dc *DeviceClient) BrowserTransparencyURL(token string) string {
return transparencyURL.String()
}
+// BrowserSelfServiceURL returns the "Self-service" URL for the browser.
+func (dc *DeviceClient) BrowserSelfServiceURL(token string) string {
+ selfServiceURL := dc.baseClient.url("/device/"+token+"/self-service", "")
+ if dc.fleetAlternativeBrowserHost != "" {
+ selfServiceURL.Host = dc.fleetAlternativeBrowserHost
+ }
+ return selfServiceURL.String()
+}
+
// BrowserDeviceURL returns the "My device" URL for the browser.
func (dc *DeviceClient) BrowserDeviceURL(token string) string {
deviceURL := dc.baseClient.url("/device/"+token, "")
diff --git a/server/service/devices.go b/server/service/devices.go
index 55239a91c6..01d463d5ef 100644
--- a/server/service/devices.go
+++ b/server/service/devices.go
@@ -110,6 +110,7 @@ type getDeviceHostResponse struct {
Host *HostDetailResponse `json:"host"`
OrgLogoURL string `json:"org_logo_url"`
OrgLogoURLLightBackground string `json:"org_logo_url_light_background"`
+ OrgContactURL string `json:"org_contact_url"`
Err error `json:"error,omitempty"`
License fleet.LicenseInfo `json:"license"`
GlobalConfig fleet.DeviceGlobalConfig `json:"global_config"`
@@ -152,12 +153,6 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S
return getDeviceHostResponse{Err: err}, nil
}
- deviceGlobalConfig := fleet.DeviceGlobalConfig{
- MDM: fleet.DeviceGlobalMDMConfig{
- EnabledAndConfigured: ac.MDM.EnabledAndConfigured,
- },
- }
-
resp.DEPAssignedToFleet = ptr.Bool(false)
if ac.MDM.EnabledAndConfigured && license.IsPremium() {
hdep, err := svc.GetHostDEPAssignment(ctx, host)
@@ -167,11 +162,36 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S
resp.DEPAssignedToFleet = ptr.Bool(hdep.IsDEPAssignedToFleet())
}
+ softwareInventoryEnabled := ac.Features.EnableSoftwareInventory
+ if resp.TeamID != nil {
+ // load the team to get the device's team's software inventory config.
+ tm, err := svc.GetTeam(ctx, *resp.TeamID)
+ if err != nil && !fleet.IsNotFound(err) {
+ return getDeviceHostResponse{Err: err}, nil
+ }
+ if tm != nil {
+ softwareInventoryEnabled = tm.Config.Features.EnableSoftwareInventory
+ }
+ }
+
+ deviceGlobalConfig := fleet.DeviceGlobalConfig{
+ MDM: fleet.DeviceGlobalMDMConfig{
+ // TODO(mna): It currently only returns the Apple enabled and configured,
+ // regardless of the platform of the device. See
+ // https://github.com/fleetdm/fleet/pull/19304#discussion_r1618792410.
+ EnabledAndConfigured: ac.MDM.EnabledAndConfigured,
+ },
+ Features: fleet.DeviceFeatures{
+ EnableSoftwareInventory: softwareInventoryEnabled,
+ },
+ }
+
return getDeviceHostResponse{
- Host: resp,
- OrgLogoURL: ac.OrgInfo.OrgLogoURL,
- License: *license,
- GlobalConfig: deviceGlobalConfig,
+ Host: resp,
+ OrgLogoURL: ac.OrgInfo.OrgLogoURL,
+ OrgContactURL: ac.OrgInfo.ContactURL,
+ License: *license,
+ GlobalConfig: deviceGlobalConfig,
}, nil
}
@@ -601,8 +621,8 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos
////////////////////////////////////////////////////////////////////////////////
type getDeviceSoftwareRequest struct {
- Token string `url:"token"`
- ListOptions fleet.ListOptions `url:"list_options"`
+ Token string `url:"token"`
+ fleet.HostSoftwareTitleListOptions
}
func (r *getDeviceSoftwareRequest) deviceAuthToken() string {
@@ -626,7 +646,7 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle
}
req := request.(*getDeviceSoftwareRequest)
- res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.ListOptions)
+ res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.HostSoftwareTitleListOptions)
if err != nil {
return getDeviceSoftwareResponse{Err: err}, nil
}
diff --git a/server/service/handler.go b/server/service/handler.go
index 7f4484f990..22d9e09a8f 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -776,6 +776,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
de.WithCustomMiddleware(
errorLimiter.Limit("get_device_software", desktopQuota),
).GET("/api/_version_/fleet/device/{token}/software", getDeviceSoftwareEndpoint, getDeviceSoftwareRequest{})
+ de.WithCustomMiddleware(
+ errorLimiter.Limit("install_self_service", desktopQuota),
+ ).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{})
// mdm-related endpoints available via device authentication
demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())
diff --git a/server/service/hosts.go b/server/service/hosts.go
index a30cb412fa..530c7fd829 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -2462,8 +2462,8 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label
////////////////////////////////////////////////////////////////////////////////
type getHostSoftwareRequest struct {
- ID uint `url:"id"`
- ListOptions fleet.ListOptions `url:"list_options"`
+ ID uint `url:"id"`
+ fleet.HostSoftwareTitleListOptions
}
type getHostSoftwareResponse struct {
@@ -2477,7 +2477,7 @@ func (r getHostSoftwareResponse) error() error { return r.Err }
func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getHostSoftwareRequest)
- res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.ListOptions)
+ res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.HostSoftwareTitleListOptions)
if err != nil {
return getHostSoftwareResponse{Err: err}, nil
}
@@ -2487,9 +2487,11 @@ func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet
return getHostSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil
}
-func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
- // if the request is token-authenticated ("My device" page), we don't include software
- // that is not installed but for which there's an installer available for that host.
+func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
+ // if the request is token-authenticated ("My device" page), we don't include
+ // software that is not installed but for which there's an installer
+ // available for that host (unless the request filters for self-service
+ // software only).
var includeAvailableForInstall bool
var host *fleet.Host
@@ -2519,15 +2521,23 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee
}
// cursor-based pagination is not supported
- opts.After = ""
+ opts.ListOptions.After = ""
// custom ordering is not supported, always by name (but asc/desc is configurable)
- opts.OrderKey = "name"
+ opts.ListOptions.OrderKey = "name"
// always include metadata
- opts.IncludeMetadata = true
+ opts.ListOptions.IncludeMetadata = true
+ opts.IncludeAvailableForInstall = includeAvailableForInstall || opts.SelfServiceOnly
- software, meta, err := svc.ds.ListHostSoftware(ctx, host, includeAvailableForInstall, opts)
- if !includeAvailableForInstall {
- // for the device page, we don't want to return the package name
+ software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
+ if includeAvailableForInstall {
+ // for the host software page, we don't want to return the package object,
+ // only the package name
+ for _, s := range software {
+ s.Package = nil
+ }
+ } else {
+ // for the device page, we don't want to return the package name, only the
+ // package object
for _, s := range software {
s.PackageAvailableForInstall = nil
}
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index 511dc29d97..bc07f5d7d2 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -617,7 +617,7 @@ func TestHostAuth(t *testing.T) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
- ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
+ ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
return nil, nil, nil
}
@@ -762,10 +762,10 @@ func TestHostAuth(t *testing.T) {
_, err = svc.SetCustomHostDeviceMapping(ctx, 2, "a@b.c")
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
- _, _, err = svc.ListHostSoftware(ctx, 1, fleet.ListOptions{})
+ _, _, err = svc.ListHostSoftware(ctx, 1, fleet.HostSoftwareTitleListOptions{})
checkAuthErr(t, tt.shouldFailTeamRead, err)
- _, _, err = svc.ListHostSoftware(ctx, 2, fleet.ListOptions{})
+ _, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
}
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index 5347ca84bd..a5af5c5d36 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -6672,7 +6672,7 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() {
// create a bunch of software
sws := make([]fleet.Software, 20)
for i := range sws {
- sw := fleet.Software{Name: "sw" + strconv.Itoa(i), Version: "0.0." + strconv.Itoa(i), Source: "apps"}
+ sw := fleet.Software{Name: fmt.Sprintf("sw%02d", i), Version: fmt.Sprintf("0.0.%02d", i), Source: "apps"}
if i%2 == 0 {
sw.Source = "chrome_extensions"
sw.Browser = "chrome"
@@ -11254,7 +11254,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
require.NoError(t, err)
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
require.NoError(t, err)
- h1Foo, err := s.ds.InsertSoftwareInstallRequest(ctx, host1.ID, s1Meta.InstallerID)
+ h1Foo, err := s.ds.InsertSoftwareInstallRequest(ctx, host1.ID, s1Meta.InstallerID, false)
require.NoError(t, err)
// force an order to the activities
diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go
index d3d994fd05..6a540eda51 100644
--- a/server/service/integration_desktop_test.go
+++ b/server/service/integration_desktop_test.go
@@ -22,6 +22,8 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() {
ac, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
ac.OrgInfo.OrgLogoURL = "http://example.com/logo"
+ ac.OrgInfo.ContactURL = "http://example.com/contact"
+ ac.Features.EnableSoftwareInventory = true
err = s.ds.SaveAppConfig(context.Background(), ac)
require.NoError(t, err)
@@ -74,10 +76,12 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() {
require.Equal(t, hosts[0].ID, getHostResp.Host.ID)
require.False(t, getHostResp.Host.RefetchRequested)
require.Equal(t, "http://example.com/logo", getHostResp.OrgLogoURL)
+ require.Equal(t, "http://example.com/contact", getHostResp.OrgContactURL)
require.Nil(t, getHostResp.Host.Policies)
require.NotNil(t, getHostResp.Host.Batteries)
require.Equal(t, &fleet.HostBattery{CycleCount: 1, Health: "Normal"}, (*getHostResp.Host.Batteries)[0])
require.True(t, getHostResp.GlobalConfig.MDM.EnabledAndConfigured)
+ require.True(t, getHostResp.GlobalConfig.Features.EnableSoftwareInventory)
hostDevResp := getHostResp.Host
// make request for same host on the host details API endpoint,
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 279efe8df2..6993bc6eb0 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -80,7 +80,11 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() {
return func() (fleet.CronSchedule, error) {
// We set 24-hour interval so that it only runs when triggered.
var err error
- calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, log.NewJSONLogger(os.Stdout))
+ cronLog := log.NewJSONLogger(os.Stdout)
+ if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
+ cronLog = kitlog.NewNopLogger()
+ }
+ calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, cronLog)
return calendarSchedule, err
}
},
@@ -2471,14 +2475,22 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() {
func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
t := s.T()
+ ctx := context.Background()
// set the logo via the modify appconfig endpoint, so that the cache is
// properly updated.
var acResp appConfigResponse
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"org_info":{"org_logo_url": "http://example.com/logo"}}`), http.StatusOK, &acResp)
+ s.DoJSON("PATCH", "/api/latest/fleet/config",
+ json.RawMessage(`{
+ "org_info":{
+ "org_logo_url": "http://example.com/logo",
+ "contact_url": "http://example.com/contact"
+ }
+ }`), http.StatusOK, &acResp)
require.Equal(t, "http://example.com/logo", acResp.OrgInfo.OrgLogoURL)
+ require.Equal(t, "http://example.com/contact", acResp.OrgInfo.ContactURL)
- team, err := s.ds.NewTeam(context.Background(), &fleet.Team{
+ team, err := s.ds.NewTeam(ctx, &fleet.Team{
ID: 51,
Name: "team1-policies",
Description: "desc team1",
@@ -2487,10 +2499,10 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
token := "much_valid"
host := createHostAndDeviceToken(t, s.ds, token)
- err = s.ds.AddHostsToTeam(context.Background(), &team.ID, []uint{host.ID})
+ err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{host.ID})
require.NoError(t, err)
- qr, err := s.ds.NewQuery(context.Background(), &fleet.Query{
+ qr, err := s.ds.NewQuery(ctx, &fleet.Query{
Name: "TestQueryEnterpriseGlobalPolicy",
Description: "Some description",
Query: "select * from osquery;",
@@ -2509,7 +2521,7 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
require.NotNil(t, gpResp.Policy)
// add a policy execution
- require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host,
+ require.NoError(t, s.ds.RecordPolicyQueryExecutions(ctx, host,
map[uint]*bool{gpResp.Policy.ID: ptr.Bool(false)}, time.Now(), false))
// add a policy to team
@@ -2534,7 +2546,7 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
}
require.NoError(t, u.SetPassword(password, 10, 10))
- _, err = s.ds.NewUser(context.Background(), u)
+ _, err = s.ds.NewUser(ctx, u)
require.NoError(t, err)
s.token = s.getTestToken(email, password)
@@ -2574,7 +2586,9 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
require.Equal(t, host.ID, getDeviceHostResp.Host.ID)
require.False(t, getDeviceHostResp.Host.RefetchRequested)
require.Equal(t, "http://example.com/logo", getDeviceHostResp.OrgLogoURL)
+ require.Equal(t, "http://example.com/contact", getDeviceHostResp.OrgContactURL)
require.Len(t, *getDeviceHostResp.Host.Policies, 2)
+ require.False(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory)
// GET `/api/_version_/fleet/device/{token}/desktop`
getDesktopResp := fleetDesktopResponse{}
@@ -2586,6 +2600,17 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
require.NoError(t, getDesktopResp.Err)
require.Equal(t, *getDesktopResp.FailingPolicies, uint(1))
require.False(t, getDesktopResp.Notifications.NeedsMDMMigration)
+
+ // update the team to enable software inventory
+ team.Config.Features.EnableSoftwareInventory = true
+ _, err = s.ds.SaveTeam(ctx, team)
+ require.NoError(t, err)
+
+ getDeviceHostResp = getDeviceHostResponse{}
+ res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
+ err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
+ require.NoError(t, err)
+ require.True(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory)
}
// TestCustomTransparencyURL tests that Fleet Premium licensees can use custom transparency urls.
@@ -7010,6 +7035,16 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
sort.Slice(want, func(i, j int) bool {
return want[i].Name < want[j].Name
})
+ for _, v := range got {
+ sort.Slice(v.Versions, func(i, j int) bool {
+ return v.Versions[i].Version < v.Versions[j].Version
+ })
+ }
+ for _, v := range want {
+ sort.Slice(v.Versions, func(i, j int) bool {
+ return v.Versions[i].Version < v.Versions[j].Version
+ })
+ }
require.EqualValues(t, want, got)
}
@@ -7124,6 +7159,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs))
var resp listSoftwareTitlesResponse
+ // no self-service software yet
+ s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1")
+ require.Empty(t, resp.SoftwareTitles)
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp)
require.Equal(t, 2, resp.Count)
require.NotEmpty(t, resp.CountsUpdatedAt)
@@ -7137,6 +7175,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
{Version: "0.0.1", Vulnerabilities: nil},
{Version: "0.0.3", Vulnerabilities: nil},
},
+ SelfService: false,
},
{
Name: "bar",
@@ -7146,6 +7185,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
Versions: []fleet.SoftwareVersion{
{Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}},
},
+ SelfService: false,
},
}, resp.SoftwareTitles)
@@ -7626,12 +7666,20 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
)
// verify that software installers contain SoftwarePackage field
- payload := &fleet.UploadSoftwareInstallerPayload{
+ payloadRubyTm1 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
+ SelfService: false,
TeamID: &team1.ID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(payloadRubyTm1, http.StatusOK, "")
+
+ payloadEmacs := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ Filename: "emacs.deb",
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(payloadEmacs, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
@@ -7647,15 +7695,14 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage)
// Upload an installer for the same software but different arch to a different team
- payload = &fleet.UploadSoftwareInstallerPayload{
+ payloadRubyTm2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby_arm64.deb",
TeamID: &team2.ID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(payloadRubyTm2, http.StatusOK, "")
// We should only see the one we uploaded to team 1
-
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
@@ -7664,10 +7711,50 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
"query", "ruby",
"team_id", fmt.Sprintf("%d", team1.ID),
)
-
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage)
+
+ // software installer not returned with self-service only (not marked as such)
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp,
+ "self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID))
+ require.Len(t, resp.SoftwareTitles, 0)
+
+ // update it to be self-service, check that it gets returned
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payloadRubyTm1.Filename)
+ return err
+ })
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp,
+ "self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID))
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage)
+ require.True(t, *&resp.SoftwareTitles[0].SelfService)
+
+ // no team but self-service returns the emacs software (technically impossible via the UI)
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "self_service", "true",
+ )
+
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ require.Equal(t, "emacs.deb", *resp.SoftwareTitles[0].SoftwarePackage)
+ require.True(t, *&resp.SoftwareTitles[0].SelfService)
+
+ emacsPath := fmt.Sprintf("/api/latest/fleet/software/titles/%d", resp.SoftwareTitles[0].ID)
+ respTitle := getSoftwareTitleResponse{}
+ s.DoJSON("GET", emacsPath, listSoftwareTitlesRequest{}, http.StatusOK, &respTitle)
+
+ require.NotNil(t, respTitle.SoftwareTitle)
+ require.Equal(t, "emacs.deb", respTitle.SoftwareTitle.SoftwarePackage.Name)
+ require.True(t, respTitle.SoftwareTitle.SoftwarePackage.SelfService)
}
func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() {
@@ -8940,12 +9027,18 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
var getHostSw getHostSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 0)
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
+ require.Len(t, getHostSw.Software, 0)
var getDeviceSw getDeviceSoftwareResponse
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
err := json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 0)
+ res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
+ err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
+ require.NoError(t, err)
+ require.Len(t, getDeviceSw.Software, 0)
// create some software for that host
software := []fleet.Software{
@@ -8964,6 +9057,13 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, getHostSw.Software[0].Name, "bar")
require.Equal(t, getHostSw.Software[1].Name, "foo")
require.Len(t, getHostSw.Software[1].InstalledVersions, 2)
+ // no package information as there is no installer
+ require.Nil(t, getHostSw.Software[0].SelfService)
+ require.Nil(t, getHostSw.Software[0].Package)
+ require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall)
+ require.Nil(t, getHostSw.Software[1].SelfService)
+ require.Nil(t, getHostSw.Software[1].Package)
+ require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall)
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
@@ -8973,26 +9073,52 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, getDeviceSw.Software[0].Name, "bar")
require.Equal(t, getDeviceSw.Software[1].Name, "foo")
require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2)
+ // no package information as there is no installer
+ require.Nil(t, getHostSw.Software[0].SelfService)
+ require.Nil(t, getHostSw.Software[0].Package)
+ require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall)
+ require.Nil(t, getHostSw.Software[1].SelfService)
+ require.Nil(t, getHostSw.Software[1].Package)
+ require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall)
// create a software installer, not installed on the host
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
+ Version: "1:2.5.1",
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages")
+ // update it to be self-service
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payload.Filename)
+ return err
+ })
+
// available installer is returned by user-authenticated endpoint
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb
require.Equal(t, getHostSw.Software[0].Name, "bar")
+ require.Nil(t, getHostSw.Software[0].PackageAvailableForInstall)
require.Equal(t, getHostSw.Software[1].Name, "foo")
+ require.Nil(t, getHostSw.Software[1].PackageAvailableForInstall)
require.Equal(t, getHostSw.Software[2].Name, "ruby")
require.Len(t, getHostSw.Software[1].InstalledVersions, 2)
require.NotNil(t, getHostSw.Software[2].PackageAvailableForInstall)
require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall)
+ require.NotNil(t, getHostSw.Software[2].SelfService)
+ require.True(t, *getHostSw.Software[2].SelfService)
require.Nil(t, getHostSw.Software[2].Status)
+ // package object is not returned for user-authenticated endpoint
+ require.Nil(t, getHostSw.Software[2].Package)
+
+ // only the installer is returned for self-service only
+ getHostSw = getHostSoftwareResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
+ require.Len(t, getHostSw.Software, 1)
+ require.Equal(t, getHostSw.Software[0].Name, "ruby")
// available installer is not returned by device-authenticated endpoint
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
@@ -9006,6 +9132,22 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall)
require.Nil(t, getDeviceSw.Software[1].PackageAvailableForInstall)
+ // but it gets returned for self-service only
+ res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
+ getDeviceSw = getDeviceSoftwareResponse{}
+ err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
+ require.NoError(t, err)
+ require.Len(t, getDeviceSw.Software, 1)
+ require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
+ // package available for install is not returned for device-authenticated
+ require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall)
+ // but package object is
+ require.NotNil(t, getDeviceSw.Software[0].Package)
+ require.NotNil(t, getDeviceSw.Software[0].SelfService)
+ require.True(t, *getDeviceSw.Software[0].SelfService)
+ require.Equal(t, payload.Filename, getDeviceSw.Software[0].Package.Name)
+ require.Equal(t, payload.Version, getDeviceSw.Software[0].Package.Version)
+
// request installation on the host
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d",
@@ -9023,8 +9165,17 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall)
require.NotNil(t, getHostSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status)
+ require.NotNil(t, getHostSw.Software[2].SelfService)
+ require.True(t, *getHostSw.Software[2].SelfService)
+ require.Nil(t, getHostSw.Software[2].Package)
- // now returned by device-authenticated endpoin
+ // still returned with self-service filter
+ getHostSw = getHostSoftwareResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
+ require.Len(t, getHostSw.Software, 1)
+ require.Equal(t, getHostSw.Software[0].Name, "ruby")
+
+ // now returned by device-authenticated endpoint
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
@@ -9037,6 +9188,21 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Nil(t, getDeviceSw.Software[2].PackageAvailableForInstall)
require.NotNil(t, getDeviceSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status)
+ require.NotNil(t, getDeviceSw.Software[2].SelfService)
+ require.True(t, *getDeviceSw.Software[2].SelfService)
+ require.NotNil(t, getDeviceSw.Software[2].Package)
+
+ // still returned for self-service only too
+ res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
+ getDeviceSw = getDeviceSoftwareResponse{}
+ err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
+ require.NoError(t, err)
+ require.Len(t, getDeviceSw.Software, 1)
+ require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
+ require.NotNil(t, getDeviceSw.Software[0].SelfService)
+ require.True(t, *getDeviceSw.Software[0].SelfService)
+ require.NotNil(t, getDeviceSw.Software[0].Package)
+ require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall)
// test with a query
getHostSw = getHostSoftwareResponse{}
@@ -9165,7 +9331,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
// check activity
- s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null}`, 0)
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false}`, 0)
// check the software installer
_, titleID := checkSoftwareInstaller(t, payload)
@@ -9200,11 +9366,12 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
PostInstallScript: "another post install script",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
- Title: "ruby",
- Version: "1:2.5.1",
- Source: "deb_packages",
- StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
- Platform: "linux",
+ Title: "ruby",
+ Version: "1:2.5.1",
+ Source: "deb_packages",
+ StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
+ Platform: "linux",
+ SelfService: true,
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
@@ -9212,7 +9379,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
installerID, titleID := checkSoftwareInstaller(t, payload)
// check activity
- s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
// upload again fails
s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists")
@@ -9252,7 +9419,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID))
// check activity
- s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
})
}
@@ -9279,7 +9446,8 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
"name": teamName,
"software": []map[string]any{
{
- "url": "http://foo.com",
+ "url": "http://foo.com",
+ "self_service": true,
"install_script": map[string]string{
"path": "./foo/install-script.sh",
},
@@ -9311,12 +9479,14 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
wantSoftware := []fleet.TeamSpecSoftware{
{
URL: "http://foo.com",
+ SelfService: true,
InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/install-script.sh"},
PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/post-install-script.sh"},
PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./foo/query.yaml"},
},
{
URL: "http://bar.com",
+ SelfService: false,
InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/install-script.sh"},
PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/post-install-script.sh"},
PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./bar/query.yaml"},
@@ -9749,6 +9919,88 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.")
}
+func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
+ t := s.T()
+
+ host1 := createOrbitEnrolledHost(t, "linux", "", s.ds)
+ token := "secret_token"
+ createDeviceTokenForHost(t, s.ds, host1.ID, token)
+
+ payloadNoSS := &fleet.UploadSoftwareInstallerPayload{
+ PreInstallQuery: "SELECT 1",
+ InstallScript: "install",
+ PostInstallScript: "echo hi",
+ Filename: "ruby.deb",
+ Title: "ruby",
+ SelfService: false,
+ }
+ s.uploadSoftwareInstaller(payloadNoSS, http.StatusOK, "")
+ titleIDNoSS := getSoftwareTitleID(t, s.ds, payloadNoSS.Title, "deb_packages")
+
+ payloadSS := &fleet.UploadSoftwareInstallerPayload{
+ PreInstallQuery: "SELECT 2",
+ InstallScript: "install again",
+ PostInstallScript: "echo bye",
+ Filename: "emacs.deb",
+ Title: "emacs",
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(payloadSS, http.StatusOK, "")
+ titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
+
+ // cannot self-install if software installer does not allow it
+ res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDNoSS), nil, http.StatusBadRequest)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Software title is not available through self-service")
+
+ // request self-install of software that allows it
+ s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDSS), nil, http.StatusAccepted)
+
+ // it shows up as "self-installed" in the upcoming activities of the host
+ var listUpcomingAct listHostUpcomingActivitiesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct)
+ require.Len(t, listUpcomingAct.Activities, 1)
+ require.Nil(t, listUpcomingAct.Activities[0].ActorID)
+
+ var details fleet.ActivityTypeInstalledSoftware
+ err := json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
+ require.NoError(t, err)
+ require.Equal(t, host1.ID, details.HostID)
+ require.Equal(t, details.SoftwareTitle, payloadSS.Title)
+ require.True(t, details.SelfService)
+ require.EqualValues(t, fleet.SoftwareInstallerPending, details.Status)
+ installID := details.InstallUUID
+
+ // record the installation results
+ 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"
+ }`, *host1.OrbitNodeKey, installID)),
+ http.StatusNoContent)
+
+ // nothing in upcoming activities anymore
+ listUpcomingAct = listHostUpcomingActivitiesResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listUpcomingAct)
+ require.Len(t, listUpcomingAct.Activities, 0)
+
+ // installation shows up in past activities
+ var listPastAct listActivitiesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host1.ID), nil, http.StatusOK, &listPastAct)
+ require.Len(t, listPastAct.Activities, 1)
+ require.Nil(t, listPastAct.Activities[0].ActorID)
+
+ err = json.Unmarshal([]byte(*listPastAct.Activities[0].Details), &details)
+ require.NoError(t, err)
+ require.Equal(t, host1.ID, details.HostID)
+ require.Equal(t, details.SoftwareTitle, payloadSS.Title)
+ require.True(t, details.SelfService)
+ require.EqualValues(t, fleet.SoftwareInstallerInstalled, details.Status)
+}
+
func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
ctx := context.Background()
t := s.T()
@@ -9822,6 +10074,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload.Title,
+ SoftwarePackage: payload.Filename,
InstallUUID: installUUIDs[0],
Status: string(fleet.SoftwareInstallerFailed),
}
@@ -9909,6 +10162,9 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.
require.NoError(t, w.WriteField("install_script", payload.InstallScript))
require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery))
require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript))
+ if payload.SelfService {
+ require.NoError(t, w.WriteField("self_service", "true"))
+ }
w.Close()
diff --git a/server/service/orbit.go b/server/service/orbit.go
index 8cfb8f43aa..e5b4c4f474 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -910,7 +910,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
}
var user *fleet.User
- if hsi.UserID != nil {
+ if hsi.UserID != nil && !hsi.SelfService {
user, err = svc.ds.UserByID(ctx, *hsi.UserID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host software installation user")
@@ -924,8 +924,10 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: hsi.SoftwareTitle,
+ SoftwarePackage: hsi.SoftwarePackage,
InstallUUID: result.InstallUUID,
Status: string(status),
+ SelfService: hsi.SelfService,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for software installation")
diff --git a/server/service/orbit_client_test.go b/server/service/orbit_client_test.go
index 377d8b0c69..8f76595a60 100644
--- a/server/service/orbit_client_test.go
+++ b/server/service/orbit_client_test.go
@@ -149,10 +149,11 @@ func TestExecuteConfigReceiversCancel(t *testing.T) {
func TestExecuteConfigReceiversInterrupt(t *testing.T) {
client := clientWithConfig(&fleet.OrbitConfig{})
- client.ReceiverUpdateInterval = 200 * time.Millisecond
+ defer client.ReceiverUpdateCancelFunc()
+
+ client.ReceiverUpdateInterval = 100 * time.Millisecond
var called bool
-
rfunc := fleet.OrbitConfigReceiverFunc(func(cfg *fleet.OrbitConfig) error {
called = true
return nil
@@ -160,14 +161,13 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) {
client.RegisterConfigReceiver(rfunc)
- finChan := make(chan error, 1)
-
+ finChan := make(chan error)
go func() {
finChan <- client.ExecuteConfigReceivers()
}()
go func() {
- time.Sleep(200 * time.Millisecond)
+ time.Sleep(500 * time.Millisecond)
client.ReceiverUpdateCancelFunc()
}()
@@ -178,6 +178,4 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) {
case <-time.NewTimer(2 * time.Second).C:
require.Fail(t, "receiver interrupt cancel didn't work")
}
-
- client.ReceiverUpdateCancelFunc()
}
diff --git a/server/service/software_installers.go b/server/service/software_installers.go
index 9a51b7f5f4..f03f05d0b7 100644
--- a/server/service/software_installers.go
+++ b/server/service/software_installers.go
@@ -9,6 +9,8 @@ import (
"strconv"
"github.com/docker/go-units"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
@@ -20,6 +22,7 @@ type uploadSoftwareInstallerRequest struct {
InstallScript string
PreInstallQuery string
PostInstallScript string
+ SelfService bool
}
type uploadSoftwareInstallerResponse struct {
@@ -79,6 +82,15 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
decoded.PostInstallScript = val[0]
}
+ val, ok = r.MultipartForm.Value["self_service"]
+ if ok && len(val) > 0 && val[0] != "" {
+ parsed, err := strconv.ParseBool(val[0])
+ if err != nil {
+ return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())}
+ }
+ decoded.SelfService = parsed
+ }
+
return &decoded, nil
}
@@ -99,6 +111,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
PostInstallScript: req.PostInstallScript,
InstallerFile: ff,
Filename: req.File.Filename,
+ SelfService: req.SelfService,
}
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
@@ -312,3 +325,46 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin
return fleet.ErrMissingLicense
}
+
+//////////////////////////////////////////////////////////////////////////////
+// Self Service Install
+//////////////////////////////////////////////////////////////////////////////
+
+type fleetSelfServiceSoftwareInstallRequest struct {
+ Token string `url:"token"`
+ SoftwareTitleID uint `url:"software_title_id"`
+}
+
+func (r *fleetSelfServiceSoftwareInstallRequest) deviceAuthToken() string {
+ return r.Token
+}
+
+type submitSelfServiceSoftwareInstallResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r submitSelfServiceSoftwareInstallResponse) error() error { return r.Err }
+func (r submitSelfServiceSoftwareInstallResponse) Status() int { return http.StatusAccepted }
+
+func submitSelfServiceSoftwareInstall(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ host, ok := hostctx.FromContext(ctx)
+ if !ok {
+ err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
+ return submitSelfServiceSoftwareInstallResponse{Err: err}, nil
+ }
+
+ req := request.(*fleetSelfServiceSoftwareInstallRequest)
+ if err := svc.SelfServiceInstallSoftwareTitle(ctx, host, req.SoftwareTitleID); err != nil {
+ return submitSelfServiceSoftwareInstallResponse{Err: err}, nil
+ }
+
+ return submitSelfServiceSoftwareInstallResponse{}, nil
+}
+
+func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return fleet.ErrMissingLicense
+}
diff --git a/server/service/testdata/software-installers/emacs.deb b/server/service/testdata/software-installers/emacs.deb
new file mode 100644
index 0000000000..90c58f1045
Binary files /dev/null and b/server/service/testdata/software-installers/emacs.deb differ