diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx
new file mode 100644
index 000000000000..21a59614b279
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+
+import { createMockOSVersionsResponse } from "__mocks__/softwareMock";
+
+import SoftwareOSTable from "./SoftwareOSTable";
+
+// TODO: figure out how to mock the router properly.
+const mockRouter = {
+ push: jest.fn(),
+ replace: jest.fn(),
+ goBack: jest.fn(),
+ goForward: jest.fn(),
+ go: jest.fn(),
+ setRouteLeaveHook: jest.fn(),
+ isActive: jest.fn(),
+ createHref: jest.fn(),
+ createPath: jest.fn(),
+};
+
+describe("Software operating systems table", () => {
+ it("Renders the page-wide disabled state when software inventory is disabled", async () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Software inventory disabled")).toBeInTheDocument();
+ });
+
+ it("Renders the page-wide empty state when no software is present", () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText("No operating systems detected")
+ ).toBeInTheDocument();
+ expect(screen.getByText("0 items")).toBeInTheDocument();
+ expect(screen.queryByText("Search")).toBeNull();
+ expect(screen.queryByText("Updated")).toBeNull();
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx
index b04f99fbe5e1..9be6ee4d1626 100644
--- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx
@@ -129,7 +129,7 @@ const SoftwareOSTable = ({
};
const renderSoftwareCount = () => {
- if (!data?.os_versions || !data?.count) return null;
+ if (!data) return null;
return (
<>
diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx
index 574c05367898..9dcf95250fe2 100644
--- a/frontend/pages/SoftwarePage/SoftwarePage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx
@@ -36,9 +36,10 @@ import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal"
import AddSoftwareModal from "./components/AddSoftwareModal";
import {
buildSoftwareFilterQueryParams,
+ buildSoftwareVulnFiltersQueryParams,
getSoftwareFilterFromQueryParams,
getSoftwareVulnFiltersFromQueryParams,
- ISoftwareVulnFilters,
+ ISoftwareVulnFiltersParams,
} from "./SoftwareTitles/SoftwareTable/helpers";
import SoftwareFiltersModal from "./components/SoftwareFiltersModal";
@@ -308,7 +309,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
[handleTeamChange]
);
- const onApplyVulnFilters = (vulnFilters: ISoftwareVulnFilters) => {
+ const onApplyVulnFilters = (vulnFilters: ISoftwareVulnFiltersParams) => {
const newQueryParams: ISoftwareApiParams = {
query,
teamId: currentTeamId,
@@ -316,7 +317,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
orderKey: sortHeader,
page: 0, // resets page index
...buildSoftwareFilterQueryParams(softwareFilter),
- ...vulnFilters,
+ ...buildSoftwareVulnFiltersQueryParams(vulnFilters),
};
router.replace(
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx
index b3f7c31146b5..5cfc313e4416 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx
@@ -38,6 +38,7 @@ const AdvancedOptionsModal = ({
helpText="Fleet will run this command on hosts to install software."
label="Install script"
labelTooltip="For security agents, add the script provided by the vendor."
+ isFormField
/>
{preInstallQuery && (
@@ -72,6 +73,7 @@ const AdvancedOptionsModal = ({
maxLines={10}
value={postInstallScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
+ isFormField
/>
)}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
index 23d03c58521c..fa75f1d5541d 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
@@ -3,11 +3,15 @@ import React, { useCallback, useContext } from "react";
import softwareAPI from "services/entities/software";
import { NotificationContext } from "context/notification";
+import { getErrorReason } from "interfaces/errors";
+
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "delete-software-modal";
+const DELETE_SW_USED_BY_POLICY_ERROR_MSG =
+ "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.";
interface IDeleteSoftwareModalProps {
softwareId: number;
teamId: number;
@@ -28,8 +32,13 @@ const DeleteSoftwareModal = ({
await softwareAPI.deleteSoftwarePackage(softwareId, teamId);
renderFlash("success", "Software deleted successfully!");
onSuccess();
- } catch {
- renderFlash("error", "Couldn't delete. Please try again.");
+ } catch (error) {
+ const reason = getErrorReason(error);
+ if (reason.includes("Policy automation uses this software")) {
+ renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG);
+ } else {
+ renderFlash("error", "Couldn't delete. Please try again.");
+ }
}
onExit();
}, [softwareId, teamId, renderFlash, onSuccess, onExit]);
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
index 8453abfbb6bf..1c6a31e9d769 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
@@ -4,8 +4,6 @@ import React, {
useLayoutEffect,
useState,
} from "react";
-import FileSaver from "file-saver";
-import { parse } from "content-disposition";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@@ -45,10 +43,15 @@ function useTruncatedElement
(ref: React.RefObject) {
useLayoutEffect(() => {
const element = ref.current;
- if (element) {
- const { scrollWidth, clientWidth } = element;
- setIsTruncated(scrollWidth > clientWidth);
+ function updateIsTruncated() {
+ if (element) {
+ const { scrollWidth, clientWidth } = element;
+ setIsTruncated(scrollWidth > clientWidth);
+ }
}
+ window.addEventListener("resize", updateIsTruncated);
+ updateIsTruncated();
+ return () => window.removeEventListener("resize", updateIsTruncated);
}, [ref]);
return isTruncated;
@@ -92,20 +95,29 @@ const STATUS_DISPLAY_OPTIONS: Record<
iconName: "success",
tooltip: (
<>
- Fleet installed software on these hosts. Currently, if the software is
- uninstalled, the "Installed" status won't be updated.
+ Software is installed on these hosts (install script finished
+
+ with exit code 0). Currently, if the software is uninstalled, the
+
+ "installed" status won't be updated.
>
),
},
pending: {
displayName: "Pending",
iconName: "pending-outline",
- tooltip: "Fleet will install software when these hosts come online.",
+ tooltip: "Fleet is installing or will install when the host comes online.",
},
failed: {
displayName: "Failed",
iconName: "error",
- tooltip: "Fleet failed to install software on these hosts.",
+ tooltip: (
+ <>
+ These hosts failed to install software. Click on a host to view
+
+ error(s).
+ >
+ ),
},
};
@@ -130,16 +142,18 @@ const PackageStatusCount = ({
})}`;
return (
-
{displayData.displayName}
+
{displayData.displayName}
}
@@ -305,7 +319,7 @@ const SoftwarePackageCard = ({
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 */}
@@ -315,46 +329,46 @@ const SoftwarePackageCard = ({
{renderDetails()}
-
-
-
-
+
+ {isSelfService && (
+
+
+ Self-service
+
+ )}
+ {showActions && (
+
+ )}
-
- {isSelfService && (
-
-
- Self-service
-
- )}
- {showActions && (
-
- )}
+
{showAdvancedOptionsModal && (
>
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx
index 4eb9660e625a..6eaafa2a28e0 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx
@@ -13,6 +13,7 @@ import TableContainer from "components/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
+import LastUpdatedText from "components/LastUpdatedText";
import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig";
@@ -21,6 +22,21 @@ const DEFAULT_SORT_DIRECTION = "desc";
const baseClass = "software-title-details-table";
+const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => {
+ return (
+
+ The last time software data was
+ updated, including vulnerabilities
+ and host counts.
+ >
+ }
+ />
+ );
+};
+
const NoVersionsDetected = (isAvailableForInstall = false): JSX.Element => {
return (
{
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
@@ -95,7 +113,10 @@ const SoftwareTitleDetailsTable = ({
);
const renderVersionsCount = () => (
-
+ <>
+
+ {countsUpdatedAt && SoftwareLastUpdatedInfo(countsUpdatedAt)}
+ >
);
return (
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx
index add93fc3b17e..1c07bc5cd7ea 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx
@@ -4,8 +4,8 @@ import { createCustomRenderer } from "test/test-utils";
import createMockUser from "__mocks__/userMock";
import {
- createMockSoftwareTitlesReponse,
- createMockSoftwareVersionsReponse,
+ createMockSoftwareTitlesResponse,
+ createMockSoftwareVersionsResponse,
} from "__mocks__/softwareMock";
import { noop } from "lodash";
@@ -25,7 +25,7 @@ const mockRouter = {
};
describe("Software table", () => {
- it("Renders the page-wide disabled state when software inventory is disabled", async () => {
+ it("Renders the page-wide disabled state when software inventory is disabled", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -40,7 +40,7 @@ describe("Software table", () => {
router={mockRouter}
isSoftwareEnabled={false} // Set to false
showVersions={false}
- data={createMockSoftwareTitlesReponse({
+ data={createMockSoftwareTitlesResponse({
counts_updated_at: null,
software_titles: [],
})}
@@ -68,7 +68,7 @@ describe("Software table", () => {
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders the page-wide empty state when no software are present", async () => {
+ it("Renders the page-wide empty state when no software are present", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -83,7 +83,8 @@ describe("Software table", () => {
router={mockRouter}
isSoftwareEnabled
showVersions={false}
- data={createMockSoftwareTitlesReponse({
+ data={createMockSoftwareTitlesResponse({
+ count: 0,
counts_updated_at: null,
software_titles: [],
})}
@@ -111,11 +112,12 @@ describe("Software table", () => {
expect(
screen.getByText("Expecting to see software? Check back later.")
).toBeInTheDocument();
+ expect(screen.getByText("0 items")).toBeInTheDocument();
expect(screen.queryByText("Search")).toBeNull();
expect(screen.queryByText("Updated")).toBeNull();
});
- it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", async () => {
+ it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -130,7 +132,7 @@ describe("Software table", () => {
router={mockRouter}
isSoftwareEnabled
showVersions // Versions toggle applied
- data={createMockSoftwareVersionsReponse({
+ data={createMockSoftwareVersionsResponse({
counts_updated_at: null,
software: [],
})}
@@ -160,7 +162,7 @@ describe("Software table", () => {
).toBeInTheDocument();
});
- it("Renders the empty search state when search query does not exist but dropdown is applied", async () => {
+ it("Renders the empty search state when search query does not exist but dropdown is applied", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -175,7 +177,7 @@ describe("Software table", () => {
router={mockRouter}
isSoftwareEnabled
showVersions={false}
- data={createMockSoftwareTitlesReponse({
+ data={createMockSoftwareTitlesResponse({
counts_updated_at: null,
software_titles: [],
})}
@@ -209,7 +211,7 @@ describe("Software table", () => {
).toBeInTheDocument();
});
- it("Renders the empty search state when search query does not exist but vulnerability filter is applied", async () => {
+ it("Renders the empty search state when search query does not exist but vulnerability filter is applied", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -224,7 +226,7 @@ describe("Software table", () => {
router={mockRouter}
isSoftwareEnabled
showVersions={false}
- data={createMockSoftwareTitlesReponse({
+ data={createMockSoftwareTitlesResponse({
counts_updated_at: null,
software_titles: [],
})}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
index 498599053782..9b8df49c2cf9 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx
@@ -269,7 +269,7 @@ const SoftwareTable = ({
};
const renderSoftwareCount = () => {
- if (!tableData || !data?.count) return null;
+ if (!tableData || !data) return null;
return (
<>
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts
new file mode 100644
index 000000000000..aed9762f450c
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts
@@ -0,0 +1,49 @@
+import { isValidNumber } from "./helpers";
+
+describe("isValidNumber", () => {
+ // Test valid numbers
+ it("returns true for valid numbers", () => {
+ expect(isValidNumber(0)).toBe(true);
+ expect(isValidNumber(42)).toBe(true);
+ expect(isValidNumber(-10)).toBe(true);
+ expect(isValidNumber(3.14)).toBe(true);
+ });
+
+ // Test invalid inputs
+ it("returns false for non-number inputs", () => {
+ expect(isValidNumber("42")).toBe(false);
+ expect(isValidNumber(null)).toBe(false);
+ expect(isValidNumber(undefined)).toBe(false);
+ expect(isValidNumber({})).toBe(false);
+ expect(isValidNumber([])).toBe(false);
+ expect(isValidNumber(true)).toBe(false);
+ });
+
+ // Test NaN
+ it("returns false for NaN", () => {
+ expect(isValidNumber(NaN)).toBe(false);
+ });
+
+ // Test with min value
+ it("respects min value when provided", () => {
+ expect(isValidNumber(5, 0)).toBe(true);
+ expect(isValidNumber(5, 5)).toBe(true);
+ expect(isValidNumber(5, 6)).toBe(false);
+ });
+
+ // Test with max value
+ it("respects max value when provided", () => {
+ expect(isValidNumber(5, undefined, 10)).toBe(true);
+ expect(isValidNumber(5, undefined, 5)).toBe(true);
+ expect(isValidNumber(5, undefined, 4)).toBe(false);
+ });
+
+ // Test with both min and max values
+ it("respects both min and max values when provided", () => {
+ expect(isValidNumber(5, 0, 10)).toBe(true);
+ expect(isValidNumber(0, 0, 10)).toBe(true);
+ expect(isValidNumber(10, 0, 10)).toBe(true);
+ expect(isValidNumber(-1, 0, 10)).toBe(false);
+ expect(isValidNumber(11, 0, 10)).toBe(false);
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
index 90abdfb14774..b4dacf1c7d03 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
+++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts
@@ -127,7 +127,7 @@ export type ISoftwareVulnFiltersParams = {
maxCvssScore?: number;
};
-const isValidNumber = (
+export const isValidNumber = (
value: any,
min?: number,
max?: number
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx
index ea0ac483897c..2e869892d182 100644
--- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx
@@ -24,7 +24,7 @@ const mockRouter = {
};
describe("Software Vulnerabilities table", () => {
- it("Renders the page-wide disabled state when software inventory is disabled", async () => {
+ it("Renders the page-wide disabled state when software inventory is disabled", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -62,7 +62,7 @@ describe("Software Vulnerabilities table", () => {
});
// TODO: Reinstate collecting software view
- it("Renders the page-wide empty state when no software vulnerabilities are present", async () => {
+ it("Renders the page-wide empty state when no software vulnerabilities are present", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -97,13 +97,14 @@ describe("Software Vulnerabilities table", () => {
);
expect(screen.getByText("No vulnerabilities detected")).toBeInTheDocument();
+ expect(screen.getByText("0 items")).toBeInTheDocument();
expect(
screen.getByText("Expecting to see vulnerabilities? Check back later.")
).toBeInTheDocument();
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", async () => {
+ it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -145,7 +146,7 @@ describe("Software Vulnerabilities table", () => {
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", async () => {
+ it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -188,7 +189,7 @@ describe("Software Vulnerabilities table", () => {
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", async () => {
+ it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -233,7 +234,7 @@ describe("Software Vulnerabilities table", () => {
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", async () => {
+ it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", () => {
const render = createCustomRenderer({
context: {
app: {
@@ -276,7 +277,7 @@ describe("Software Vulnerabilities table", () => {
expect(screen.queryByText("Vulnerability")).toBeNull();
});
- it("Renders premium columns", async () => {
+ it("Renders premium columns", () => {
const render = createCustomRenderer({
context: {
app: {
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx
index d37eb63acbd1..390a3dc86682 100644
--- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx
@@ -197,9 +197,9 @@ const SoftwareVulnerabilitiesTable = ({
};
const renderVulnerabilityCount = () => {
- if (!data?.count) return null;
+ if (!data) return null;
- const count = data.count;
+ const count = data?.count;
return (
<>
diff --git a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx
index 2e7e4b17a566..52cb805f7f46 100644
--- a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx
+++ b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx
@@ -1,13 +1,19 @@
import React, { useContext, useEffect, useState } from "react";
import { InjectedRouter } from "react-router";
+import { getErrorReason } from "interfaces/errors";
+
import PATHS from "router/paths";
import { NotificationContext } from "context/notification";
import softwareAPI from "services/entities/software";
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
+import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
+
+import CustomLink from "components/CustomLink";
+
import AddPackageForm from "../AddPackageForm";
-import { IAddSoftwareFormData } from "../AddPackageForm/AddSoftwareForm";
+import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm";
import { getErrorMessage } from "../AddSoftwareModal/helpers";
const baseClass = "add-package";
@@ -60,7 +66,7 @@ const AddPackage = ({
};
}, [isUploading]);
- const onAddPackage = async (formData: IAddSoftwareFormData) => {
+ const onAddPackage = async (formData: IAddPackageFormData) => {
setIsUploading(true);
if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) {
@@ -98,6 +104,21 @@ const AddPackage = ({
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
);
} catch (e) {
+ const reason = getErrorReason(e);
+ if (
+ reason.includes("Couldn't add. Fleet couldn't read the version from")
+ ) {
+ renderFlash(
+ "error",
+ `${reason}. ${(
+
+ )} `
+ );
+ }
renderFlash("error", getErrorMessage(e));
}
diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx
new file mode 100644
index 000000000000..5d6524e8dce1
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx
@@ -0,0 +1,101 @@
+import React, { useState } from "react";
+
+import Editor from "components/Editor";
+import CustomLink from "components/CustomLink";
+import FleetAce from "components/FleetAce";
+import RevealButton from "components/buttons/RevealButton";
+
+const baseClass = "add-package-advanced-options";
+
+interface IAddPackageAdvancedOptionsProps {
+ errors: { preInstallQuery?: string; postInstallScript?: string };
+ preInstallQuery?: string;
+ installScript: string;
+ postInstallScript?: string;
+ onChangePreInstallQuery: (value?: string) => void;
+ onChangeInstallScript: (value: string) => void;
+ onChangePostInstallScript: (value?: string) => void;
+}
+
+const AddPackageAdvancedOptions = ({
+ errors,
+ preInstallQuery,
+ installScript,
+ postInstallScript,
+ onChangePreInstallQuery,
+ onChangeInstallScript,
+ onChangePostInstallScript,
+}: IAddPackageAdvancedOptionsProps) => {
+ const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
+
+ return (
+
+
setShowAdvancedOptions(!showAdvancedOptions)}
+ />
+ {showAdvancedOptions && (
+
+
+ Software will be installed only if the{" "}
+
+ >
+ }
+ />
+
+ Fleet will run this script on hosts to install software. Use the
+
+ $INSTALLER_PATH variable to point to the installer.
+ >
+ }
+ isFormField
+ />
+
+
+ )}
+
+ );
+};
+
+export default AddPackageAdvancedOptions;
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss
similarity index 88%
rename from frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss
rename to frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss
index 58f1f85892b9..0728e3241560 100644
--- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss
@@ -1,4 +1,4 @@
-.add-software-advanced-options {
+.add-package-advanced-options {
display: flex;
flex-direction: column;
align-items: flex-start;
diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts
new file mode 100644
index 000000000000..004c96332d47
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddPackageAdvancedOptions";
diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx
similarity index 55%
rename from frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx
rename to frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx
index aab97985d0d0..cf3802b3f6f8 100644
--- a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx
+++ b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx
@@ -6,7 +6,6 @@ import getInstallScript from "utilities/software_install_scripts";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
-import Editor from "components/Editor";
import {
FileUploader,
FileDetails,
@@ -14,25 +13,25 @@ import {
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
-import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
+import AddPackageAdvancedOptions from "../AddPackageAdvancedOptions";
import { generateFormValidation } from "./helpers";
-export const baseClass = "add-software-form";
+export const baseClass = "add-package-form";
const UploadingSoftware = () => {
return (
-
Uploading. It may take a few minutes to finish.
+
Adding software. This may take a few minutes to finish.
);
};
-export interface IAddSoftwareFormData {
+export interface IAddPackageFormData {
software: File | null;
installScript: string;
- preInstallCondition?: string;
+ preInstallQuery?: string;
postInstallScript?: string;
selfService: boolean;
}
@@ -40,30 +39,28 @@ export interface IAddSoftwareFormData {
export interface IFormValidation {
isValid: boolean;
software: { isValid: boolean };
- preInstallCondition?: { isValid: boolean; message?: string };
+ preInstallQuery?: { isValid: boolean; message?: string };
postInstallScript?: { isValid: boolean; message?: string };
selfService?: { isValid: boolean };
}
-interface IAddSoftwareFormProps {
+interface IAddPackageFormProps {
isUploading: boolean;
onCancel: () => void;
- onSubmit: (formData: IAddSoftwareFormData) => void;
+ onSubmit: (formData: IAddPackageFormData) => void;
}
-const AddSoftwareForm = ({
+const AddPackageForm = ({
isUploading,
onCancel,
onSubmit,
-}: IAddSoftwareFormProps) => {
+}: IAddPackageFormProps) => {
const { renderFlash } = useContext(NotificationContext);
- const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
- const [showPostInstallScript, setShowPostInstallScript] = useState(false);
- const [formData, setFormData] = useState({
+ const [formData, setFormData] = useState({
software: null,
installScript: "",
- preInstallCondition: undefined,
+ preInstallQuery: undefined,
postInstallScript: undefined,
selfService: false,
});
@@ -90,13 +87,7 @@ const AddSoftwareForm = ({
installScript,
};
setFormData(newData);
- setFormValidation(
- generateFormValidation(
- newData,
- showPreInstallCondition,
- showPostInstallScript
- )
- );
+ setFormValidation(generateFormValidation(newData));
}
};
@@ -105,62 +96,26 @@ const AddSoftwareForm = ({
onSubmit(formData);
};
- const onTogglePreInstallConditionCheckbox = (value: boolean) => {
- const newData = { ...formData, preInstallCondition: undefined };
- setShowPreInstallCondition(value);
- setFormData(newData);
- setFormValidation(
- generateFormValidation(newData, value, showPostInstallScript)
- );
- };
-
- const onTogglePostInstallScriptCheckbox = (value: boolean) => {
- const newData = { ...formData, postInstallScript: undefined };
- setShowPostInstallScript(value);
- setFormData(newData);
- setFormValidation(
- generateFormValidation(newData, showPreInstallCondition, value)
- );
- };
-
const onChangeInstallScript = (value: string) => {
setFormData({ ...formData, installScript: value });
};
- const onChangePreInstallCondition = (value?: string) => {
- const newData = { ...formData, preInstallCondition: value };
+ const onChangePreInstallQuery = (value?: string) => {
+ const newData = { ...formData, preInstallQuery: value };
setFormData(newData);
- setFormValidation(
- generateFormValidation(
- newData,
- showPreInstallCondition,
- showPostInstallScript
- )
- );
+ setFormValidation(generateFormValidation(newData));
};
const onChangePostInstallScript = (value?: string) => {
const newData = { ...formData, postInstallScript: value };
setFormData(newData);
- setFormValidation(
- generateFormValidation(
- newData,
- showPreInstallCondition,
- showPostInstallScript
- )
- );
+ setFormValidation(generateFormValidation(newData));
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
setFormData(newData);
- setFormValidation(
- generateFormValidation(
- newData,
- showPreInstallCondition,
- showPostInstallScript
- )
- );
+ setFormValidation(generateFormValidation(newData));
};
const isSubmitDisabled = !formValidation.isValid;
@@ -185,25 +140,6 @@ const AddSoftwareForm = ({
)
}
/>
- {formData.software && (
-
- For security agents, add the script provided by the vendor.
-
- In custom scripts, you can use the $INSTALLER_PATH variable to
- point to the installer.
- >
- }
- />
- )}
-
@@ -247,4 +181,4 @@ const AddSoftwareForm = ({
);
};
-export default AddSoftwareForm;
+export default AddPackageForm;
diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss b/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss
index 8e09f0a64d53..aee078016f36 100644
--- a/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss
@@ -1,4 +1,4 @@
-.add-software-form {
+.add-package-form {
&__uploading-message {
display: flex;
align-items: center;
diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts b/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts
index d38ca110d5d1..d527049c1474 100644
--- a/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts
+++ b/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts
@@ -3,23 +3,19 @@ import validator from "validator";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
-import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
+import { IAddPackageFormData, IFormValidation } from "./AddPackageForm";
-type IAddSoftwareFormValidatorKey = Exclude<
- keyof IAddSoftwareFormData,
+type IAddPackageFormValidatorKey = Exclude<
+ keyof IAddPackageFormData,
"installScript"
>;
-type IMessageFunc = (formData: IAddSoftwareFormData) => string;
+type IMessageFunc = (formData: IAddPackageFormData) => string;
type IValidationMessage = string | IMessageFunc;
interface IValidation {
name: string;
- isValid: (
- formData: IAddSoftwareFormData,
- enabledPreInstallCondition?: boolean,
- enabledPostInstallScript?: boolean
- ) => boolean;
+ isValid: (formData: IAddPackageFormData) => boolean;
message?: IValidationMessage;
}
@@ -27,7 +23,7 @@ interface IValidation {
* to determine if a field is valid, and rules for generating an error message.
*/
const FORM_VALIDATION_CONFIG: Record<
- IAddSoftwareFormValidatorKey,
+ IAddPackageFormValidatorKey,
{ validations: IValidation[] }
> = {
software: {
@@ -38,70 +34,23 @@ const FORM_VALIDATION_CONFIG: Record<
},
],
},
- preInstallCondition: {
+ preInstallQuery: {
validations: [
- {
- name: "required",
- isValid: (
- formData: IAddSoftwareFormData,
- enabledPreInstallCondition
- ) => {
- if (!enabledPreInstallCondition) {
- return true;
- }
- return (
- formData.preInstallCondition !== undefined &&
- !validator.isEmpty(formData.preInstallCondition)
- );
- },
- message: (formData) => {
- // we dont want an error message until the user has interacted with
- // the field. This is why we check for undefined here.
- if (formData.preInstallCondition === undefined) {
- return "";
- }
- return "Pre-install condition is required when enabled.";
- },
- },
{
name: "invalidQuery",
- isValid: (formData, enabledPreInstallCondition) => {
- if (!enabledPreInstallCondition) {
- return true;
- }
+ isValid: (formData) => {
+ const query = formData.preInstallQuery;
return (
- formData.preInstallCondition !== undefined &&
- validateQuery(formData.preInstallCondition).valid
+ query === undefined || query === "" || validateQuery(query).valid
);
},
- message: (formData) =>
- validateQuery(formData.preInstallCondition).error,
+ message: (formData) => validateQuery(formData.preInstallQuery).error,
},
],
},
postInstallScript: {
- validations: [
- {
- name: "required",
- message: (formData) => {
- // we dont want an error message until the user has interacted with
- // the field. This is why we check for undefined here.
- if (formData.postInstallScript === undefined) {
- return "";
- }
- return "Post-install script is required when enabled.";
- },
- isValid: (formData, _, enabledPostInstallScript) => {
- if (!enabledPostInstallScript) {
- return true;
- }
- return (
- formData.postInstallScript !== undefined &&
- !validator.isEmpty(formData.postInstallScript)
- );
- },
- },
- ],
+ // no validations related to postInstallScript
+ validations: [],
},
selfService: {
// no validations related to self service
@@ -110,7 +59,7 @@ const FORM_VALIDATION_CONFIG: Record<
};
const getErrorMessage = (
- formData: IAddSoftwareFormData,
+ formData: IAddPackageFormData,
message?: IValidationMessage
) => {
if (message === undefined || typeof message === "string") {
@@ -119,11 +68,7 @@ const getErrorMessage = (
return message(formData);
};
-export const generateFormValidation = (
- formData: IAddSoftwareFormData,
- showingPreInstallCondition: boolean,
- showingPostInstallScript: boolean
-) => {
+export const generateFormValidation = (formData: IAddPackageFormData) => {
const formValidation: IFormValidation = {
isValid: true,
software: {
@@ -134,12 +79,7 @@ export const generateFormValidation = (
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
- (validation) =>
- !validation.isValid(
- formData,
- showingPreInstallCondition,
- showingPostInstallScript
- )
+ (validation) => !validation.isValid(formData)
);
if (!failedValidation) {
diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts b/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts
index d3ea76d47d3c..2795e058dc01 100644
--- a/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts
+++ b/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts
@@ -1 +1 @@
-export { default } from "./AddSoftwareForm";
+export { default } from "./AddPackageForm";
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx
deleted file mode 100644
index 96de54fd33f4..000000000000
--- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React, { useState } from "react";
-
-import Editor from "components/Editor";
-import CustomLink from "components/CustomLink";
-import FleetAce from "components/FleetAce";
-import RevealButton from "components/buttons/RevealButton";
-import Checkbox from "components/forms/fields/Checkbox";
-
-const baseClass = "add-software-advanced-options";
-
-interface IAddSoftwareAdvancedOptionsProps {
- errors: { preInstallCondition?: string; postInstallScript?: string };
- showPreInstallCondition: boolean;
- showPostInstallScript: boolean;
- preInstallCondition?: string;
- postInstallScript?: string;
- onTogglePreInstallCondition: (value: boolean) => void;
- onTogglePostInstallScript: (value: boolean) => void;
- onChangePreInstallCondition: (value?: string) => void;
- onChangePostInstallScript: (value?: string) => void;
-}
-
-const AddSoftwareAdvancedOptions = ({
- errors,
- showPreInstallCondition,
- showPostInstallScript,
- preInstallCondition,
- postInstallScript,
- onTogglePreInstallCondition,
- onTogglePostInstallScript,
- onChangePreInstallCondition,
- onChangePostInstallScript,
-}: IAddSoftwareAdvancedOptionsProps) => {
- const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
-
- const onChangePreInstallCheckbox = () => {
- onTogglePreInstallCondition(!showPreInstallCondition);
- };
-
- const onChangePostInstallCheckbox = () => {
- onTogglePostInstallScript(!showPostInstallScript);
- };
-
- return (
-
-
setShowAdvancedOptions(!showAdvancedOptions)}
- />
- {showAdvancedOptions && (
-
-
- Pre-install condition
-
- {showPreInstallCondition && (
-
- Software will be installed only if the{" "}
-
- >
- }
- />
- )}
-
- Post-install script
-
- {showPostInstallScript && (
- <>
-
- >
- )}
-
- )}
-
- );
-};
-
-export default AddSoftwareAdvancedOptions;
diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts
deleted file mode 100644
index 264fa61b112e..000000000000
--- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./AddSoftwareAdvancedOptions";
diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx
index 06108649bf72..ffde9220a271 100644
--- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx
+++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx
@@ -35,14 +35,14 @@ const EnableVppCard = () => {
- Volume Purchasing Program (VPP) isn't enabled
+ No Volume Purchasing Program (VPP) token assigned
- To add App Store apps, first enable VPP.
+ To add App Store apps, assign a VPP token to this team.
@@ -57,9 +57,8 @@ const NoVppAppsCard = () => (
You don't have any App Store apps
- Add apps in{" "}
- Apps
- that are already added to this team are not listed.
+ You must purchase apps in ABM. App Store apps that are already added to
+ this team are not listed.
diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss
index e530588b7a4a..604750a25808 100644
--- a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss
@@ -53,6 +53,7 @@
&__no-software-description {
margin: 0;
color: $ui-fleet-black-75;
+ text-align: center;
}
&__error {
diff --git a/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx b/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx
index 0c9ec70b3528..91223b5746b5 100644
--- a/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx
+++ b/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx
@@ -18,7 +18,7 @@ const baseClass = "software-filters-modal";
interface ISoftwareFiltersModalProps {
onExit: () => void;
- onSubmit: (vulnFilters: ISoftwareVulnFilters) => void;
+ onSubmit: (vulnFilters: ISoftwareVulnFiltersParams) => void;
vulnFilters: ISoftwareVulnFiltersParams;
isPremiumTier: boolean;
}
@@ -53,8 +53,8 @@ const SoftwareFiltersModal = ({
onSubmit({
vulnerable: vulnSoftwareFilterEnabled,
exploit: hasKnownExploit || undefined,
- min_cvss_score: severity?.minSeverity || undefined,
- max_cvss_score: severity?.maxSeverity || undefined,
+ minCvssScore: severity?.minSeverity || undefined,
+ maxCvssScore: severity?.maxSeverity || undefined,
});
};
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx
index ff3cf5b53994..b6e0f02795f0 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx
@@ -27,6 +27,8 @@ interface IMdmSettingsProps {
const MdmSettings = ({ router }: IMdmSettingsProps) => {
const { isPremiumTier, config } = useContext(AppContext);
+ const isMdmEnabled = !!config?.mdm.enabled_and_configured;
+
// Currently the status of this API call is what determines various UI states on
// this page. Because of this we will not render any of this components UI until this API
// call has completed.
@@ -48,7 +50,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
// we're fetching and setting the config, but for now we'll just assume that any 400 response
// means that MDM is not enabled and we'll show the "Turn on MDM" button.
staleTime: 5000,
- enabled: !!config?.mdm.enabled_and_configured,
+ enabled: isMdmEnabled,
}
);
@@ -63,7 +65,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
{
...DEFAULT_USE_QUERY_OPTIONS,
retry: false,
- enabled: isPremiumTier && !!config?.mdm.enabled_and_configured,
+ enabled: isPremiumTier && isMdmEnabled,
}
);
@@ -80,7 +82,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
{
...DEFAULT_USE_QUERY_OPTIONS,
retry: false,
- enabled: isPremiumTier && !!config?.mdm.enabled_and_configured,
+ enabled: isPremiumTier && isMdmEnabled,
}
);
@@ -104,7 +106,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
// we use this to determine if we have all the data we need to render the UI.
// Notice that we do not need VPP or EULA data to render this page.
- const hasAllData = !!APNSInfo;
+ const hasAllData = !isMdmEnabled || !!APNSInfo;
return (
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx
index 77dc84278241..7716c6de6c92 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx
@@ -20,7 +20,7 @@ export const getErrorMessage = (err: unknown) => {
reasonIncludes: "Duplicate entry",
});
const invalidTokenReason = getErrorReason(err, {
- reasonIncludes: "invalid",
+ reasonIncludes: "Invalid token",
});
if (duplicateEntryReason) {
@@ -28,7 +28,7 @@ export const getErrorMessage = (err: unknown) => {
}
if (invalidTokenReason) {
- return "Invalid token. Please provide a valid token from Apple Business Manager.";
+ return invalidTokenReason;
}
return DEFAULT_ERROR_MESSAGE;
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx
index 8635499a0ced..029380e60e17 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx
@@ -209,7 +209,7 @@ const EditTeamsVppModal = ({
showArrow
tipContent={
- You canβt choose teams because you already have a VPP token
+ You can't choose teams because you already have a VPP token
assigned to all teams. First, edit teams for that VPP token to
choose teams here.
@@ -223,6 +223,7 @@ const EditTeamsVppModal = ({
placeholder="Search teams"
value={selectedValue}
label="Teams"
+ className={`${baseClass}__vpp-dropdown`}
wrapperClassName={`${baseClass}__form-field--vpp-teams ${
isDropdownDisabled ? `${baseClass}__form-field--disabled` : ""
}`}
@@ -230,7 +231,7 @@ const EditTeamsVppModal = ({
isDropdownDisabled ? undefined : (
<>
Each team can have only one VPP token. Teams that already
- have a VPP token wonβt show up here.
+ have a VPP token won't show up here.
>
)
}
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss
index 9b6f508dc1a6..72ed9e4a10f1 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss
@@ -2,6 +2,13 @@
.component__tooltip-wrapper__element {
width: 100%; // default component style was causing the select box not to be full width
}
+
+ // this is needed to wrap the selected team names in that are displayed
+ // in the dropdown select box.
+ .dropdown__select {
+ text-wrap: wrap;
+ }
+
// styles needed to make select look like figma design when disabled,
// default styles in the Dropdown component were not enough
&__form-field--disabled {
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx
index dd0ca4c96d36..69d2c65ad542 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx
@@ -25,7 +25,7 @@ const AppleAutomaticEnrollmentCard = ({
"Add an Apple Business Manager (ABM) connection to automatically enroll newly " +
"purchased Apple hosts when they're first unboxed and set up by your end users.";
} else if (isAppleMdmOn && configured) {
- msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) hosts enabled.";
+ msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) is enabled.";
icon = "success";
}
diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx
index e2b58dd29b4a..afcd3af3c213 100644
--- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx
+++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx
@@ -46,7 +46,7 @@ const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => {
- Volume Purchasing Program (VPP) enabled.
+ Volume Purchasing Program (VPP) is enabled.
diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
index 4971fdf086a3..172b6e4bd9f5 100644
--- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
+++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
@@ -53,7 +53,7 @@ import {
HOST_OSQUERY_DATA,
} from "utilities/constants";
-import { isIPadOrIPhone, Platform } from "interfaces/platform";
+import { isIPadOrIPhone } from "interfaces/platform";
import Spinner from "components/Spinner";
import TabsWrapper from "components/TabsWrapper";
@@ -479,7 +479,7 @@ const HostDetailsPage = ({
case "ios":
return mdmConfig?.ios_updates;
default:
- null;
+ return undefined;
}
};
diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx
index ef2135dc19ed..a254ea2fa4a8 100644
--- a/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx
+++ b/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx
@@ -25,11 +25,15 @@ const UnenrollMdmModal = ({ hostId, onClose }: IUnenrollMdmModalProps) => {
setRequestState("unenrolling");
try {
await mdmAPI.unenrollHostFromMdm(hostId, 5000);
- renderFlash("success", "Successfully turned off MDM.");
+ renderFlash(
+ "success",
+ "Turning off MDM or will turn off when the host comes online."
+ );
onClose();
} catch (unenrollMdmError: unknown) {
+ renderFlash("error", "Couldn't turn off MDM. Please try again.");
console.log(unenrollMdmError);
- setRequestState("error");
+ onClose();
}
};
diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx
index a9c916f57083..f86226a967c6 100644
--- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx
+++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx
@@ -43,7 +43,7 @@ const OSSettingsModal = ({
title="OS settings"
onExit={onClose}
className={baseClass}
- width="large"
+ width="xlarge"
>
<>
{
+ console.log(hostMdmProfiles);
// for windows hosts we have to manually add a profile for disk encryption
// as this is not currently included in the `profiles` value from the API
// response for windows hosts.
diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx
index b99503903d73..ef5ac1b737fb 100644
--- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx
+++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx
@@ -4,7 +4,6 @@ import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import { IHostSoftware, SoftwareInstallStatus } from "interfaces/software";
-import { dateAgo } from "utilities/date_format";
import Icon from "components/Icon";
import TextCell from "components/TableContainer/DataTable/TextCell";
@@ -14,6 +13,7 @@ const baseClass = "install-status-cell";
type IStatusValue = SoftwareInstallStatus | "avaiableForInstall";
interface TootipArgs {
softwareName?: string | null;
+ // this field is used in My device > Self-service
lastInstalledAt?: string;
isAppStoreApp?: boolean;
}
@@ -36,26 +36,23 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
installed: {
iconName: "success",
displayText: "Installed",
- tooltip: ({ lastInstalledAt: lastInstall }) => (
- <>
- Fleet installed software on this host ({dateAgo(lastInstall as string)}
- ). Currently, if the software is uninstalled, the "Installed"
- status won't be updated.
- >
- ),
+ tooltip: () =>
+ "Software is installed (install script finished with exit code 0).",
},
pending: {
iconName: "pending-outline",
displayText: "Pending",
- tooltip: () => "Fleet will install software when the host comes online.",
+ tooltip: () =>
+ "Fleet is installing or will install when the host comes online.",
},
failed: {
iconName: "error",
displayText: "Failed",
- tooltip: ({ lastInstalledAt: lastInstall }) => (
+ tooltip: () => (
<>
- Fleet failed to install software ({dateAgo(lastInstall as string)} ago).
- Select Actions > Software details to see more.
+ The host failed to install software. To view errors, select
+
+ Actions > Show details .
>
),
},
@@ -96,10 +93,6 @@ const InstallStatusCell = ({
app_store_app,
}: IInstallStatusCellProps) => {
// FIXME: Improve the way we handle polymophism of software_package and app_store_app
- const lastInstalledAt =
- software_package?.last_install?.installed_at ||
- app_store_app?.last_install?.installed_at ||
- "";
const hasPackage = !!software_package;
const hasAppStoreApp = !!app_store_app;
@@ -140,7 +133,6 @@ const InstallStatusCell = ({
{displayConfig.tooltip({
softwareName,
- lastInstalledAt,
isAppStoreApp: hasAppStoreApp,
})}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx
index 2c995aa2b2d4..a7ca5e5cdd6c 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx
@@ -166,7 +166,7 @@ describe("SelfService", () => {
).toHaveTextContent("Install");
});
- it("renders no action button with 'Install in progress...' status", async () => {
+ it("renders no action button with 'Pending' status", async () => {
mockServer.use(
customDeviceSoftwareHandler({
software: [
@@ -186,7 +186,7 @@ describe("SelfService", () => {
expect(
screen.getByTestId("self-service-item__status--test")
- ).toHaveTextContent("Install in progress...");
+ ).toHaveTextContent("Pending");
expect(
screen.queryByTestId("self-service-item__item-action-button--test")
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx
index d0fce6aca3b2..3d817c9d16c0 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx
@@ -25,25 +25,20 @@ const STATUS_CONFIG: Record = {
installed: {
iconName: "success",
displayText: "Installed",
- tooltip: ({ lastInstalledAt }) => (
- <>
- Software installed successfully ({dateAgo(lastInstalledAt as string)}).
- Currently, if the software is uninstalled, the "Installed"
- status won't be updated.
- >
- ),
+ tooltip: ({ lastInstalledAt }) =>
+ `Software is installed (${dateAgo(lastInstalledAt as string)}).`,
},
pending: {
iconName: "pending-outline",
- displayText: "Install in progress...",
- tooltip: () => "Software installation in progress...",
+ displayText: "Pending",
+ tooltip: () => "Fleet is installing software.",
},
failed: {
iconName: "error",
displayText: "Failed",
tooltip: ({ lastInstalledAt = "" }) => (
<>
- Software failed to install
+ Software failed to install{" "}
{lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
Retry to install again, or contact your IT department.
>
@@ -144,7 +139,6 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => {
case "installed":
return "Reinstall";
default:
- // we don't show a button for pending installs
return "";
}
};
@@ -165,10 +159,7 @@ const InstallerStatusAction = ({
SoftwareInstallStatus | undefined
>(undefined);
- // displayStatus allows us to display the localStatus (if any) or the status from the list
- // software reponse
- const displayStatus = localStatus || status;
- const installButtonText = getInstallButtonText(displayStatus);
+ const installButtonText = getInstallButtonText(status);
// if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we
// set this to null, which tells the tooltip to omit the parenthetical date
@@ -200,21 +191,16 @@ const InstallerStatusAction = ({
return (
-
+
{!!installButtonText && (
{installButtonText}
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss
index 027b9c4fd6c4..eb21c7d118a8 100644
--- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss
+++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss
@@ -87,9 +87,5 @@
&__item-action-button {
height: auto;
-
- &--installing {
- display: none;
- }
}
}
diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx
index ff587f290ab5..e4b97e18d124 100644
--- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx
+++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx
@@ -49,6 +49,8 @@ import AddPolicyModal from "./components/AddPolicyModal";
import DeletePolicyModal from "./components/DeletePolicyModal";
import CalendarEventsModal from "./components/CalendarEventsModal";
import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal";
+import InstallSoftwareModal from "./components/InstallSoftwareModal";
+import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal";
interface IManagePoliciesPageProps {
router: InjectedRouter;
@@ -127,12 +129,19 @@ const ManagePolicyPage = ({
const [isUpdatingCalendarEvents, setIsUpdatingCalendarEvents] = useState(
false
);
+ const [
+ isUpdatingPolicySoftwareInstall,
+ setIsUpdatingPolicySoftwareInstall,
+ ] = useState(false);
const [isUpdatingOtherWorkflows, setIsUpdatingOtherWorkflows] = useState(
false
);
const [selectedPolicyIds, setSelectedPolicyIds] = useState([]);
const [showAddPolicyModal, setShowAddPolicyModal] = useState(false);
const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false);
+ const [showInstallSoftwareModal, setShowInstallSoftwareModal] = useState(
+ false
+ );
const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false);
const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false);
const [
@@ -438,6 +447,10 @@ const ManagePolicyPage = ({
const toggleDeletePolicyModal = () =>
setShowDeletePolicyModal(!showDeletePolicyModal);
+ const toggleInstallSoftwareModal = () => {
+ setShowInstallSoftwareModal(!showInstallSoftwareModal);
+ };
+
const toggleCalendarEventsModal = () => {
setShowCalendarEventsModal(!showCalendarEventsModal);
};
@@ -447,6 +460,9 @@ const ManagePolicyPage = ({
case "calendar_events":
toggleCalendarEventsModal();
break;
+ case "install_software":
+ toggleInstallSoftwareModal();
+ break;
case "other_workflows":
toggleOtherWorkflowsModal();
break;
@@ -476,6 +492,63 @@ const ManagePolicyPage = ({
}
};
+ const onUpdatePolicySoftwareInstall = async (
+ formData: IInstallSoftwareFormData
+ ) => {
+ try {
+ setIsUpdatingPolicySoftwareInstall(true);
+ const changedPolicies = formData.filter((formPolicy) => {
+ const prevPolicyState = policiesAvailableToAutomate.find(
+ (policy) => policy.id === formPolicy.id
+ );
+
+ const turnedOff =
+ prevPolicyState?.install_software !== undefined &&
+ formPolicy.installSoftwareEnabled === false;
+
+ const turnedOn =
+ prevPolicyState?.install_software === undefined &&
+ formPolicy.installSoftwareEnabled === true;
+
+ const updatedSwId =
+ prevPolicyState?.install_software?.software_title_id !== undefined &&
+ formPolicy.swIdToInstall !==
+ prevPolicyState?.install_software?.software_title_id;
+
+ return turnedOff || turnedOn || updatedSwId;
+ });
+ if (!changedPolicies.length) {
+ renderFlash("success", "No changes detected.");
+ return;
+ }
+ const responses: Promise<
+ ReturnType
+ >[] = [];
+ responses.concat(
+ changedPolicies.map((changedPolicy) => {
+ return teamPoliciesAPI.update(changedPolicy.id, {
+ // "software_title_id:" 0 will unset software install for the policy
+ // "software_title_id": X will set the value to the given integer (except 0).
+ software_title_id: changedPolicy.swIdToInstall || 0,
+ team_id: teamIdForApi,
+ });
+ })
+ );
+ await Promise.all(responses);
+ await wait(100); // prevent race
+ refetchTeamPolicies();
+ renderFlash("success", "Successfully updated policy automations.");
+ } catch {
+ renderFlash(
+ "error",
+ "Could not update policy automations. Please try again."
+ );
+ } finally {
+ toggleInstallSoftwareModal();
+ setIsUpdatingPolicySoftwareInstall(false);
+ }
+ };
+
const onUpdateCalendarEvents = async (formData: ICalendarEventsFormData) => {
setIsUpdatingCalendarEvents(true);
@@ -698,11 +771,20 @@ const ManagePolicyPage = ({
const getAutomationsDropdownOptions = () => {
const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1;
- let disabledTooltipContent: React.ReactNode;
+ let disabledInstallTooltipContent: React.ReactNode;
+ let disabledCalendarTooltipContent: React.ReactNode;
if (!isPremiumTier) {
- disabledTooltipContent = "Available in Fleet Premium.";
+ disabledInstallTooltipContent = "Available in Fleet Premium.";
+ disabledCalendarTooltipContent = "Available in Fleet Premium.";
} else if (isAllTeams) {
- disabledTooltipContent = (
+ disabledInstallTooltipContent = (
+ <>
+ Select a team to manage
+
+ install software automation.
+ >
+ );
+ disabledCalendarTooltipContent = (
<>
Select a team to manage
@@ -715,9 +797,16 @@ const ManagePolicyPage = ({
{
label: "Calendar events",
value: "calendar_events",
- disabled: !isPremiumTier || isAllTeams,
+ disabled: !!disabledCalendarTooltipContent,
helpText: "Automatically reserve time to resolve failing policies.",
- tooltipContent: disabledTooltipContent,
+ tooltipContent: disabledCalendarTooltipContent,
+ },
+ {
+ label: "Install software",
+ value: "install_software",
+ disabled: !!disabledInstallTooltipContent,
+ helpText: "Install software to resolve failing policies.",
+ tooltipContent: disabledInstallTooltipContent,
},
{
label: "Other workflows",
@@ -816,6 +905,16 @@ const ManagePolicyPage = ({
onSubmit={onDeletePolicySubmit}
/>
)}
+ {showInstallSoftwareModal && (
+
+ )}
{showCalendarEventsModal && (
Policies:
-
+
{formData.policies.map((policy) => {
const { isChecked, name, id } = policy;
return (
-
+
Preview
-
+
);
})}
-
+
A calendar event will be created for end users if one of their hosts
fail any of these policies.{" "}
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx
new file mode 100644
index 000000000000..7c29b4979f3b
--- /dev/null
+++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx
@@ -0,0 +1,276 @@
+import React, { useCallback, useState } from "react";
+
+import { useQuery } from "react-query";
+import { omit } from "lodash";
+
+import { IPolicyStats } from "interfaces/policy";
+import softwareAPI, {
+ ISoftwareTitlesQueryKey,
+ ISoftwareTitlesResponse,
+} from "services/entities/software";
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+
+// @ts-ignore
+import Dropdown from "components/forms/fields/Dropdown";
+import Modal from "components/Modal";
+import DataError from "components/DataError";
+import Spinner from "components/Spinner";
+import Checkbox from "components/forms/fields/Checkbox";
+import TooltipTruncatedText from "components/TooltipTruncatedText";
+import CustomLink from "components/CustomLink";
+import Button from "components/buttons/Button";
+import { ISoftwareTitle } from "interfaces/software";
+
+const getPlatformDisplayFromPackageSuffix = (packageName: string) => {
+ const split = packageName.split(".");
+ const suff = split[split.length - 1];
+ switch (suff) {
+ case "pkg":
+ return "macOS";
+ case "deb":
+ return "Linux";
+ case "exe":
+ return "Windows";
+ case "msi":
+ return "Windows";
+ default:
+ return null;
+ }
+};
+
+const AFI_SOFTWARE_BATCH_SIZE = 1000;
+
+const baseClass = "install-software-modal";
+
+interface ISwDropdownField {
+ name: string;
+ value: number;
+}
+interface IFormPolicy {
+ name: string;
+ id: number;
+ installSoftwareEnabled: boolean;
+ swIdToInstall?: number;
+}
+
+export type IInstallSoftwareFormData = IFormPolicy[];
+
+interface IInstallSoftwareModal {
+ onExit: () => void;
+ onSubmit: (formData: IInstallSoftwareFormData) => void;
+ isUpdating: boolean;
+ policies: IPolicyStats[];
+ teamId: number;
+}
+const InstallSoftwareModal = ({
+ onExit,
+ onSubmit,
+ isUpdating,
+ policies,
+ teamId,
+}: IInstallSoftwareModal) => {
+ const [formData, setFormData] = useState(
+ policies.map((policy) => ({
+ name: policy.name,
+ id: policy.id,
+ installSoftwareEnabled: !!policy.install_software,
+ swIdToInstall: policy.install_software?.software_title_id,
+ }))
+ );
+
+ const anyPolicyEnabledWithoutSelectedSoftware = formData.some(
+ (policy) => policy.installSoftwareEnabled && !policy.swIdToInstall
+ );
+ const {
+ data: titlesAFI,
+ isLoading: isTitlesAFILoading,
+ isError: isTitlesAFIError,
+ } = useQuery<
+ ISoftwareTitlesResponse,
+ Error,
+ ISoftwareTitle[],
+ [ISoftwareTitlesQueryKey]
+ >(
+ [
+ {
+ scope: "software-titles",
+ page: 0,
+ perPage: AFI_SOFTWARE_BATCH_SIZE,
+ query: "",
+ orderDirection: "desc",
+ orderKey: "hosts_count",
+ teamId,
+ availableForInstall: true,
+ packagesOnly: true,
+ },
+ ],
+ ({ queryKey: [queryKey] }) =>
+ softwareAPI.getSoftwareTitles(omit(queryKey, "scope")),
+ {
+ select: (data) => data.software_titles,
+ ...DEFAULT_USE_QUERY_OPTIONS,
+ }
+ );
+
+ const onUpdateInstallSoftware = useCallback(() => {
+ onSubmit(formData);
+ }, [formData, onSubmit]);
+
+ const onChangeEnableInstallSoftware = useCallback(
+ (newVal: { policyName: string; value: boolean }) => {
+ const { policyName, value } = newVal;
+ setFormData(
+ formData.map((policy) => {
+ if (policy.name === policyName) {
+ return {
+ ...policy,
+ installSoftwareEnabled: value,
+ swIdToInstall: value ? policy.swIdToInstall : undefined,
+ };
+ }
+ return policy;
+ })
+ );
+ },
+ [formData]
+ );
+
+ const onSelectPolicySoftware = useCallback(
+ ({ name, value }: ISwDropdownField) => {
+ const [policyName, softwareId] = [name, value];
+ setFormData(
+ formData.map((policy) => {
+ if (policy.name === policyName) {
+ return { ...policy, swIdToInstall: softwareId };
+ }
+ return policy;
+ })
+ );
+ },
+ [formData]
+ );
+
+ const availableSoftwareOptions = titlesAFI?.map((title) => {
+ const platformDisplay = getPlatformDisplayFromPackageSuffix(
+ title.software_package?.name ?? ""
+ );
+ const platformString = platformDisplay ? `${platformDisplay} β’ ` : "";
+ return {
+ label: title.name,
+ value: title.id,
+ helpText: `${platformString}${title.software_package?.version ?? ""}`,
+ };
+ });
+
+ const renderPolicySwInstallOption = (policy: IFormPolicy) => {
+ const {
+ name: policyName,
+ id: policyId,
+ installSoftwareEnabled: enabled,
+ swIdToInstall,
+ } = policy;
+
+ return (
+
+ {
+ onChangeEnableInstallSoftware({
+ policyName,
+ value: !enabled,
+ });
+ }}
+ >
+
+
+ {enabled && (
+
+ )}
+
+ );
+ };
+
+ const renderContent = () => {
+ if (isTitlesAFIError) {
+ return ;
+ }
+ if (isTitlesAFILoading) {
+ return ;
+ }
+ if (!titlesAFI?.length) {
+ return (
+
+ No software available for install
+
+ Go to Software to add software to this team.
+
+
+ );
+ }
+
+ return (
+
+
+
Policies:
+
+ {formData.map((policyData) =>
+ renderPolicySwInstallOption(policyData)
+ )}
+
+
+ Selected software will be installed when hosts fail the chosen
+ policy.{" "}
+
+
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+ );
+ };
+
+ return (
+
+ {renderContent()}
+
+ );
+};
+
+export default InstallSoftwareModal;
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss
new file mode 100644
index 000000000000..de9cfc05be59
--- /dev/null
+++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss
@@ -0,0 +1,41 @@
+.manage-policies-page {
+ .install-software-modal {
+ .form-field--dropdown {
+ width: 276px;
+ .Select-placeholder {
+ color: $ui-fleet-black-50;
+ }
+ .Select-menu {
+ max-height: none;
+ overflow: visible;
+ }
+ .Select-menu-outer {
+ max-height: 240px;
+ overflow-y: auto;
+ }
+ }
+ .policy-row {
+ height: 40px;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ }
+
+ &__no-software {
+ display: flex;
+ height: 178px;
+ flex-direction: column;
+ align-items: center;
+ gap: $pad-small;
+ justify-content: center;
+ font-size: $small;
+
+ span {
+ color: $ui-fleet-black-75;
+ font-size: $xx-small;
+ }
+ }
+ .data-error {
+ padding: 78px;
+ }
+ }
+}
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts
new file mode 100644
index 000000000000..a9f46a726a03
--- /dev/null
+++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./InstallSoftwareModal";
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx
index d34ae56ff60f..7a9668e82544 100644
--- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx
+++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx
@@ -416,8 +416,8 @@ const OtherWorkflowsModal = ({
const { isChecked, name, id } = policyItem;
return (
{
@@ -220,8 +219,8 @@ export default {
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);
+ data.preInstallQuery &&
+ formData.append("pre_install_query", data.preInstallQuery);
data.postInstallScript &&
formData.append("post_install_script", data.postInstallScript);
teamId && formData.append("team_id", teamId.toString());
diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts
index a10d954e8b00..d2e13863729b 100644
--- a/frontend/services/entities/team_policies.ts
+++ b/frontend/services/entities/team_policies.ts
@@ -86,6 +86,7 @@ export default {
platform,
critical,
calendar_events_enabled,
+ software_title_id,
} = data;
const { TEAMS } = endpoints;
const path = `${TEAMS}/${team_id}/policies/${id}`;
@@ -98,6 +99,7 @@ export default {
platform,
critical,
calendar_events_enabled,
+ software_title_id,
});
},
destroy: (teamId: number | undefined, ids: number[]) => {
diff --git a/frontend/styles/byod.css b/frontend/styles/byod.css
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html
new file mode 100644
index 000000000000..6aafd2870de5
--- /dev/null
+++ b/frontend/templates/enroll-ota.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+ Fleet
+
+
+
+
+
+
+
+ Enroll your device to Fleet
+
+ Follow the instructions below to download and install the Fleet profile
+ on your device.
+
+
+
+
+ 1.
+
+ Download the Fleet profile and select Allow in the
+ pop-up.
+
+
+ Download
+
+
+
+ 2.
+
+ Navigate to Settings and select Profile Downloaded .
+
+
+
+
+
+
+
+
+ 3.
+ Select Install .
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx
index c399b5e9c107..4b780aebc98c 100644
--- a/frontend/utilities/constants.tsx
+++ b/frontend/utilities/constants.tsx
@@ -60,9 +60,13 @@ export const HOST_STATUS_WEBHOOK_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [
export const GITHUB_NEW_ISSUE_LINK =
"https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md";
-export const SUPPORT_LINK = "https://fleetdm.com/support";
+export const FLEET_WEBSITE_URL = "https://fleetdm.com";
-export const CONTACT_FLEET_LINK = "https://fleetdm.com/contact";
+export const SUPPORT_LINK = `${FLEET_WEBSITE_URL}/support`;
+
+export const CONTACT_FLEET_LINK = `${FLEET_WEBSITE_URL}/contact`;
+
+export const LEARN_MORE_ABOUT_BASE_LINK = `${FLEET_WEBSITE_URL}/learn-more-about`;
/** July 28, 2016 is the date of the initial commit to fleet/fleet. */
export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z";
diff --git a/handbook/business-operations/business-operations.rituals.yml b/handbook/business-operations/business-operations.rituals.yml
index e6df744a0a9b..fec505589810 100644
--- a/handbook/business-operations/business-operations.rituals.yml
+++ b/handbook/business-operations/business-operations.rituals.yml
@@ -52,7 +52,7 @@
task: "Prioritize for next sprint" # Title that will actually show in rituals table
startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday
frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js
- description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)"
+ description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)"
moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table
dri: "jostableford" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Enables automation of GitHub issues
diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md
index 8a21d332eeae..3190df3fc8c1 100644
--- a/handbook/company/product-groups.md
+++ b/handbook/company/product-groups.md
@@ -1,4 +1,5 @@
# π©οΈ Product groups
+
This page covers what all contributors (fleeties or not) need to know in order to contribute changes to [the core product](https://fleetdm.com/docs).
When creating software, handoffs between teams or contributors are one of the most common sources of miscommunication and waste. Like [GitLab](https://docs.google.com/document/d/1RxqS2nR5K0vN6DbgaBw7SEgpPLi0Kr9jXNGzpORT-OY/edit#heading=h.7sfw1n9c1i2t), Fleet uses product groups to minimize handoffs and maximize iteration and efficiency in the way we build the product.
@@ -6,10 +7,14 @@ When creating software, handoffs between teams or contributors are one of the mo
> - Write down philosophies and show how the pieces of the development process fit together on this "π©οΈ Product groups" page.
> - Use the dedicated [departmental](https://fleetdm.com/handbook/company#org-chart) handbook pages for [π Engineering](https://fleetdm.com/handbook/engineering) and [π¦’ Product Design](https://fleetdm.com/handbook/product) to keep track of specific, rote responsibilities and recurring rituals designed to be read and used only by people within those departments.
+
## Product roadmap
+
Fleet team members can read [Fleet's high-level product goals for the current quarter](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) (confidential Google Sheet).
+
## What are product groups?
+
Fleet organizes product development efforts into separate, cross-functional product groups that include product designers, developers, and quality engineers. These product groups are organized by business goal, and designed to operate in parallel.
Security, performance, stability, scalability, database migrations, release compatibility, usage documentation (such as REST API and configuration reference), contributor experience, and support escalation are the responsibility of every product group.
@@ -18,6 +23,7 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness)
> Ideas expressed in wireframes, like code contributions, [are welcome from everyone](https://chat.osquery.io/c/fleet), inside or outside the company.
+
## Current product groups
| Product group | Goal _(value for customers and/or community)_ | Capacity\* |
@@ -27,7 +33,9 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness)
\* The number of [estimated story points](https://fleetdm.com/handbook/company/communications#estimation-points) this group can take on per-sprint under ideal circumstances, used as a baseline number for planning and prioritizing user stories for drafting. In reality, capacity will vary as engineers are on-call, out-of-office, filling in for other product groups, etc.
+
### Endpoint ops group
+
The goal of the endpoint ops group is to increase and exceed [Fleet's product maturity goals in the endpoint operations category](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing).
| Responsibility | Human(s) |
@@ -40,7 +48,9 @@ The goal of the endpoint ops group is to increase and exceed [Fleet's product ma
> The [Slack channel](https://fleetdm.slack.com/archives/C01EZVBHFHU), [kanban release board](https://app.zenhub.com/workspaces/-g-endpoint-ops-current-sprint-63bd7e0bf75dba002a2343ac/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-endpoint-ops) for this product group is `#g-endpoint-ops`.
+
### MDM group
+
The goal of the MDM group is to increase and exceed [Fleet's product maturity goals](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing) in the "MDM" product category.
| Responsibility | Human(s) |
@@ -53,7 +63,9 @@ The goal of the MDM group is to increase and exceed [Fleet's product maturity go
> The [Slack channel](https://fleetdm.slack.com/archives/C03C41L5YEL), [kanban release board](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-mdm) for this product group is `#g-mdm`.
+
## Making changes
+
Fleet's highest product ambition is to create experiences that users want.
To deliver on this mission, we need a clear, repeatable process for turning an idea into a set of cohesively-designed changes in the product. We also need to allow [open source contributions](https://fleetdm.com/handbook/company#open-source) at any point in the process from the wider Fleet community - these won't necessarily follow this process.
@@ -65,14 +77,18 @@ To make a change to Fleet:
- Then, it will be [drafted](https://fleetdm.com/handbook/company/product-groups#drafting) (planned).
- Next, it will be [implemented](https://fleetdm.com/handbook/company/product-groups#implementing) and [released](https://fleetdm.com/handbook/engineering#release-process).
+
### Planned and unplanned changes
+
Most changes to Fleet are planned changes. They are [prioritized](https://fleetdm.com/handbook/product), defined, designed, revised, estimated, and scheduled into a release sprint _prior to starting implementation_. The process of going from a prioritized goal to an estimated, scheduled, committed user story with a target release is called "drafting", or "the drafting phase".
Occasionally, changes are unplanned. Like a patch for an unexpected bug, or a hotfix for a security issue. Or if an open source contributor suggests an unplanned change in the form of a pull request. These unplanned changes are sometimes OK to merge as-is. But if they change the user interface, the CLI usage, or the REST API, then they need to go through drafting and reconsideration before merging.
> But wait, [isn't this "waterfall"?](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) Waterfall is something else. Between 2015-2023, GitLab and The Sails Company independently developed and coevolved similar delivery processes. (What we call "drafting" and "implementation" at Fleet, is called "the validation phase" and "the build phase" at GitLab.)
+
### Experimental features
+
When a new feature is introduced it may be labeled as experimental. Experimental features are undergoing a rapid [incremental improvement and iteration process](https://fleetdm.com/handbook/company/why-this-way#why-lean-software-development) where new learnings may requires breaking changes. When we introduce experimental features, it is important that any API endpoints or configuration surface that may change in the future be clearly labeled as experimental.
1. Apply the `~experimental` label to all associated user stories.
@@ -81,7 +97,9 @@ When a new feature is introduced it may be labeled as experimental. Experimental
> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows.
+
### Breaking changes
+
For product changes that cause breaking API or configuration changes or major impact for users (or even just the _impression_ of major impact!), the company plans migration thoughtfully. If the feature was released as stable (not experimental), the product group and E-group:
1. **Written:** Write a migration guide.
@@ -92,12 +110,16 @@ For product changes that cause breaking API or configuration changes or major im
All of the steps above happen prior to any breaking changes to stable features being prioritized for implementation.
+
#### API changes
+
To maintain consistency, ensure perspective, and provide a single pair of eyes in the design of Fleet's REST API and API documentation, there is a single Directly Responsible Individual (DRI). The API design DRI will review and approve any alterations at the pull request stage, instead of making it a prerequisite during drafting of the story. You may tag the DRI in a GitHub issue with draft API specs in place to receive a review and feedback prior to implementation. Receiving a pre-review from the DRI is encouraged if the API changes introduce new endpoints, or substantially change existing endpoints.
No API changes are merged without accompanying API documentation and approval from the DRI. The DRI is responsible for ensuring that the API design remains consistent and adequately addresses both standard and edge-case scenarios. The DRI is also the code owner of the API documentation Markdown file. The DRI is committed to reviewing PRs within one business day. In instances where the DRI is unavailable, the Head of Product will act as the substitute code owner and reviewer.
+
#### Changes to tables' schema
+
Whenever a PR is proposed for making changes to our [tables' schema](https://fleetdm.com/tables/screenlock)(e.g. to schema/tables/screenlock.yml), it also has to be reflected in our osquery_fleet_schema.json file.
The website team will [periodically](https://fleetdm.com/handbook/marketing/website-handbook#rituals) update the json file with the latest changes. If the changes should be deployed sooner, you can generate the new json file yourself by running these commands:
@@ -110,14 +132,18 @@ cd website
> If a table is added to our ChromeOS extension but it does not exist in osquery or if it is a table added by fleetd, add a note that mentions it, as in this [example](https://github.com/fleetdm/fleet/blob/e95e075e77b683167e86d50960e3dc17045e3c44/schema/tables/mdm.yml#L2).
+
### Drafting
+
"Drafting" is the art of defining a change, designing and shepherding it through the drafting process until it is ready for implementation.
The goal of drafting is to deliver software that works every time with less total effort and investment, without making contribution any less fun. By researching and iterating [prior to development](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), we design better product features, crystallize fewer bad, preemptive naming decisions, and achieve better throughput: getting more done in less time.
> Fleet's drafting process is focused first and foremost on product development, but it can be used for any kind of change that benefits from planning or a "dry run". For example, imagine you work for a business who has decided to swap out one of your payroll or device management vendors. You will probably need to plan and execute changes to a number of complicated onboarding/offboarding processes.
+
#### Drafting process
+
The DRI for defining and drafting issues for a product group is the product manager, with close involvement from the designer and engineering manager. But drafting is a team effort, and all contributors participate.
A user story is considered ready for implementation once:
@@ -130,19 +156,25 @@ A user story is considered ready for implementation once:
> All user stories intended for the next sprint are estimated by the last estimation session before the sprint begins. This makes sure contributors have adequate time to complete the current sprint and provide accurate estimates for the next sprint.
+
#### Writing a good user story
+
Good user stories are short, with clear, unambiguous language.
- What screen are they looking at? (`As an observer on the host details pageβ¦`)
- What do they want to do? (`As an observer on the host details page, I want to run a permitted query.`)
- Don't get hung up on the "so that I can ________" clause. It is helpful, but optional.
- Example: "As an admin I would like to be asked for confirmation before deleting a user so that I do not accidentally delete a user."
+
#### Is it actually a story?
+
User stories are small and independently valuable.
- Is it small enough? Will this task be likely to fit in 1 sprint when estimated?
- Is it valuable enough? Will this task drive business value when released, independent of other tasks?
+
#### Defining "done"
+
To successfully deliver a user story, the people working on it need to know what "done" means.
Since the goal of a user story is to implement certain changes to the product, the "definition of done" is written and maintained by the product manager. But ultimately, this "definition of done" involves everyone in the product group. We all collectively rely on accuracy of estimations, astuteness of designs, and cohesiveness of changes envisioned in order to deliver on time and without fuss.
@@ -163,7 +195,9 @@ Things to consider when writing the "definition of done" for a user story:
- **QA:** Changes are tested by hand prior to submitting pull requests. In addition, quality assurance will do an extra QA check prior to considering this story "done". Any special QA notes?
- **Follow-through:** Is there anything in particular that we should inform others (people who aren't in this product group) about after this user story is released? For example: communication to specific customers, tips on how best to highlight this in a release post, gotchas, etc.
+
#### Providing context
+
User story issues contain an optional section called "Context".
This section is optional and hidden by default. It can be included or omitted, as time allows. As Fleet grows as an all-remote company with more asynchronous processes across timezones, we will rely on this section more and more.
@@ -181,7 +215,9 @@ Here are some examples of questions that might be helpful to answer:
These questions are helpful for the product team when considering what to prioritize. (The act of writing the answers is a lot of the value!) But these answers can also be helpful when users or contributors (including our future selves) have questions about how best to estimate, iterate, or refine.
+
#### Initiate an air guitar session
+
Anyone in the product group can initiate an air guitar session.
1. Initiate: Create a user story and add the `~air-guitar` label to indicate that it is going through the air guitar process. Air guitar issues are always intended to be designed right away. If they can't be, the requestor is notified via at-mention in the issue (that person is either the CSM or AE).
@@ -205,9 +241,12 @@ Anyone in the product group can initiate an air guitar session.
Air guitar sessions are timeboxed to ensure they are fast and focused. Documentation from this process may inform future user stories and can be invaluable when revisiting the idea at a later stage. While the air guitar process is exploratory in nature, it should be thorough enough to provide meaningful insights and data for future decision-making.
+
### Implementing
+
#### Developing from wireframes
+
Please read carefully and [pay special attention](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach) to UI wireframes.
Designs have usually gone through multiple rounds of revisions, but they could easily still be overlooking complexities or edge cases! When you think you've discovered a blocker, here's how to proceed:
@@ -226,7 +265,9 @@ At Fleet, we prioritize [iteration](https://fleetdm.com/handbook/company#results
After these considerations, if you still think you've found a blocker, alert the [appropriate PM](https://fleetdm.com/handbook/company/product-groups#current-product-groups) so that the user story can be brought back for [expedited drafting](https://fleetdm.com/handbook/product#expedited-drafting). Otherwise, make a [feature request](https://fleetdm.com/handbook/product#intake).
+
#### Sub-tasks
+
The simplest way to manage work is to use a single user story issue, then pass it around between contributors/asignees as seldom as possible. But on a case-by-case basis, for particular user stories and teams, it can sometimes be worthwhile to invest additional overhead in creating separate **unestimated sub-task** issues ("sub-tasks").
A user story is estimated to fit within 1 sprint and drives business value when released, independent of other stories. Sub-tasks are not.
@@ -241,20 +282,28 @@ Sub-tasks:
- are NOT the best place to post GitHub comments (instead, concentrate conversation in the top-level "user story" issue)
- will NOT be looked at or QA'd by quality assurance
+
## Outages
+
At Fleet, we consider an outage to be a situation where new features or previously stable features are broken or unusable.
- Occurences of outages are tracked in the [Outages](https://docs.google.com/spreadsheets/d/1a8rUk0pGlCPpPHAV60kCEUBLvavHHXbk_L3BI0ybME4/edit#gid=0) spreadsheet.
- Fleet encourages embracing the inevitability of mistakes and discourages blame games.
- Fleet stresses the critical importance of avoiding outages because they make customers' lives worse instead of better.
+
## Scaling Fleet
+
Fleet, as a Go server, scales horizontally very well. Itβs not very CPU or memory intensive. However, there are some specific gotchas to be aware of when implementing new features. Visit our [scaling Fleet page](https://fleetdm.com/handbook/engineering/scaling-fleet) for tips on scaling Fleet as efficiently and effectively as possible.
+
## Load testing
+
The [load testing page](https://fleetdm.com/handbook/engineering/load-testing) outlines the process we use to load test Fleet, and contains the results of our semi-annual load test.
+
## Version support
+
To provide the most accurate and efficient support, Fleet will only target fixes based on the latest released version. In the current version fixes, Fleet will not backport to older releases.
Community version supported for bug fixes: **Latest version only**
@@ -265,7 +314,9 @@ Premium version supported for bug fixes: **Latest version only**
Premium support for support/troubleshooting: **All versions**
+
## Release testing
+
When a release is in testing, QA should use the Slack channel #help-qa to keep everyone aware of issues found. All bugs found should be reported in the channel after creating the bug first.
When a critical bug is found, the Fleetie who labels the bug as critical is responsible for following the [critical bug notification process](https://fleetdm.com/handbook/engineering#notify-community-members-about-a-critical-bug) below.
@@ -280,7 +331,9 @@ All unreleased bugs are addressed before publishing a release. Released bugs tha
- Causes irreversible damage, such as data loss
- Introduces a security vulnerability
+
### Notify the community about a critical bug
+
We inform customers and the community about critical bugs immediately so they donβt trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the `#g-mdm` or `#g-endpoint-ops` channel with the filed bug.
If the bug finder is not a Fleetie (e.g., a member of the community), then whoever sees the critical bug should raise the alarm. Note that the bug finder here is NOT necessarily the **first** person who sees the bug. If you come across a bug you think is critical, but it has not been escalated, raise the alarm!
@@ -295,7 +348,9 @@ When a critical bug is identified, we will then follow the patch release process
> After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug.
+
## Feature fest
+
To stay in-sync with our customers' needs, Fleet accepts feature requests from customers and community members on a sprint-by-sprint basis in the regular ππ£ Feature Fest meeting. Anyone in the company is invited to submit requests or simply listen in on the ππ£ Feature Fest meeting. Folks from the wider community can also [request an invite](https://fleetdm.com/contact).
### Making a request
@@ -303,7 +358,9 @@ To make a feature request or advocate for a feature request from a customer or c
Requests are weighed from top to bottom while prioritizing attendee requests. This means that if the individual that added a feature request is not in attendance, the feature request will be discussed towards the end of the call if there's time.
+
### How feature requests are evaluated
+
Digestion of these new product ideas (requests) happens at the **ππ£ Feature Fest** meeting.
Before the **ππ£ Feature Fest** meeting, the [Customer renewals DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) goes through the "Inbox" column and removes customer requests that are not a high priority for the business. Stakeholders will be notified by the Customer renewals DRI.
@@ -326,7 +383,9 @@ Requests are weighed by:
- How well the request fits within Fleet's product vision and roadmap
- Whether the feature seems like it can be designed, estimated, and developed in 6 weeks, given its individual complexity and when combined with other work already accepted
+
### After the feature is accepted
+
After the ππ£ Feature Fest meeting, the Feature prioritization DRI will clear the Feature Fest board as follows:
**Prioritized features:** Remove `feature fest` label, add `:product` label, and move the issue to the "Ready" column in the drafting board. The request will then be assigned to a [Product Designer](https://fleetdm.com/handbook/company/product-groups#current-product-groups) during the "Design sprint kick-off" ritual.
**Put to the side features:** Remove `feature fest` label and notify the requestor.
@@ -363,14 +422,18 @@ You can read our guide to diagnosing issues in Fleet on the [debugging page](htt
- [In engineering](https://fleetdm.com/handbook/company/product-groups#in-engineering)
- [Awaiting QA](https://fleetdm.com/handbook/company/product-groups#awaiting-qa)
+
### All bugs
+
- [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+is%3Aopen+label%3Abug).
- **Bugs opened this week:** This filter returns all "bug" issues opened after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+label%3Abug+created%3A%3E%3DREPLACE_ME_YYYY-MM-DD).
- **Bugs closed this week:** This filter returns all "bug" issues closed after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on Github](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+is%3Aclosed+label%3Abug+closed%3A%3E%3DREPLACE_ME_YYYY-MM-DD).
+
#### Inbox
+
Quickly reproducing bug reports is a [priority for Fleet](https://fleetdm.com/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks). When a new bug is created using the [bug report form](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title=), it is in the "inbox" state.
At this state, the bug review DRI (QA) is responsible for going through the inbox and documenting reproduction steps, asking for more reproduction details from the reporter, or asking the product team for more guidance. QA has **1 business day** to move the bug to the next step (reproduced).
@@ -379,7 +442,9 @@ For community-reported bugs, this may require QA to gather more information from
Once reproduced, QA documents the reproduction steps in the description and moves it to the reproduced state. If QA or the engineering manager feels the bug report may be expected behavior, or if clarity is required on the intended behavior, it is assigned to the group's product manager. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+label%3Abug+label%3A%3Areproduce+sort%3Acreated-asc+).
+
#### Reproduced
+
QA has reproduced the issue successfully. It should now be transferred to engineering.
Remove the βreproduceβ label, add the following labels:
@@ -393,10 +458,14 @@ Once the bug is properly labeled, assign it to the [relevant engineering manager
> **Fast track for Fleeties:** Fleeties do not have to wait for QA to reproduce the bug. If you're confident it's reproducible, it's a bug, and the reproduction steps are well-documented, it can be moved directly to the reproduced state.
+
#### In product drafting (as needed)
+
If a bug requires input from product the `:product` label is added, the `:release` label is removed, and the PM is assigned to the issue. It will stay in this state until product closes the bug, or removes the `:product` label and assigns to an EM.
+
#### In engineering
+
A bug is in engineering after it has been reproduced and assigned to an EM. If a bug meets the criteria for a [critical bug](https://fleetdm.com/handbook/engineering#critical-bugs), the `~critical bug` label is added, and the EM follows the [critical bug notification process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#critical-bug-notification-process).
During daily standup, the EM will filter the board to only `:incoming` bugs and review with the team. The EM will remove the `:incoming` label, prioritize the bug in the "Ready" coulmn, unassign themselves, and assign an engineer or leave it unassigned for the first available engineer.
@@ -415,13 +484,19 @@ For Endpoint ops support on MDM bugs:
Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements).
+
#### Awaiting QA
+
Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" column of the relevant product group's sprint board. If the bug is verified as fixed, it is moved to the "Ready for release" column of the sprint board. Otherwise, the remaining issues are noted in a comment, and it is moved back to the "In progress" column of the sprint board.
+
## How to reach the developer on-call
+
Oncall engineers do not need to actively monitor Slack channels, except when called in by the Community or Customer teams. Members of those teams are instructed to `@oncall` in `#help-engineering` to get the attention of the on-call engineer to continue discussing any issues that come up. In some cases, the Community or Customer representative will continue to communicate with the requestor. In others, the on-call engineer will communicate directly (team members should use their judgment and discuss on a case-by-case basis how to best communicate with community members and customers).
+
### The developer on-call rotation
+
See [the internal Google Doc](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) for the engineers in the rotation.
Fleet team members can also subscribe to the [shared calendar](https://calendar.google.com/calendar/u/0?cid=Y181MzVkYThiNzMxMGQwN2QzOWEwMzU0MWRkYzc5ZmVhYjk4MmU0NzQ1ZTFjNzkzNmIwMTAxOTllOWRmOTUxZWJhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) for calendar events.
@@ -430,7 +505,9 @@ New developers are added to the on-call rotation by their manager after they hav
> The on-call rotation may be adjusted with approval from the EMs of any product groups affected. Any changes should be made before the start of the sprint so that capacity can be planned accordingly.
+
### Developer on-call responsibilities
+
- **Second-line response**
The on-call developer is a second-line responder to questions raised by customers and community members.
@@ -459,7 +536,9 @@ Fleet's documentation for contributors can be found in the [Fleet GitHub repo](h
The on-call developer is asked to read, understand, test, correct, and improve at least one doc page per week. Our goal is to 1, ensure accuracy and verify that our deployment guides and tutorials are up to date and work as expected. And 2, improve the readability, consistency, and simplicity of our documentation β with empathy towards first-time users. See [Writing documentation](https://fleetdm.com/handbook/marketing#writing-documentation) for writing guidelines, and don't hesitate to reach out to [#g-digital-experience](https://fleetdm.slack.com/archives/C01GQUZ91TN) on Slack for writing support. A backlog of documentation improvement needs is kept [here](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%22%3Aimprove+documentation%22).
+
### Escalations
+
When the on-call developer is unsure of the answer, they should follow this process for escalation.
To achieve quick "first-response" times, you are encouraged to say something like "I don't know the answer and I'm taking it back to the team," or "I think X, but I'm confirming that with the team (or by looking in the code)."
@@ -470,7 +549,9 @@ How to escalate:
2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@lukeheath` and provide the information turned up in your research. Please include possibly relevant links (even if you didn't find what you were looking for there). Luke will work with you to craft an appropriate answer or find another team member who can help.
+
### Changing of the guard
+
The on-call developer changes each week on Wednesday.
A Slack reminder should notify the on-call of the handoff. Please do the following:
@@ -487,7 +568,9 @@ In the Slack reminder thread, the on-call developer includes their retrospective
3. How did you spend the rest of your on-call week? This is a chance to demo or share what you learned.
+
## Wireframes
+
- Showing these principles and ideas, to help remember the pros and cons and conceptualize the above visually.
- Figma: [βοΈ Fleet product project](https://www.figma.com/files/project/17318630/%E2%9A%97%EF%B8%8F-Fleet-product?fuid=1234929285759903870)
@@ -501,6 +584,10 @@ Use the π§© ["Design System (current)"](https://www.figma.com/file/8oXlYXpgCV1S
Use `---`, with color `$ui-fleet-black-50` as the default UI for empty columns.
+**Images**
+
+Simple icons (aka any images used in the icon [design system component](https://www.figma.com/design/8oXlYXpgCV1Sn4ek7OworP/%F0%9F%A7%A9-Design-system-(current)?node-id=12-2&t=iO2vXbQ9Sc1kFVEJ-1)) are exported as SVGs. All other images are exported as PNGs, following the [Fleet website image](https://github.com/fleetdm/fleet/tree/main/website/assets/images) naming conventions.
+
**Form behavior**
Pressing the return or enter key with an open form will cause the form to be submitted.
@@ -560,9 +647,12 @@ OPTIONS
--host Host specified by hostname, uuid, osquery_host_id or node_key that you want to target.
```
+
## Meetings
+
### User story discovery
+
User story discovery meetings are scheduled as needed to align on large or complicated user stories. Before a discovery meeting is scheduled, the user story must be prioritized for product drafting and go through the design and specification process. When the user story is ready to be estimated, a user story discovery meeting may be scheduled to provide more dedicated, synchronous time for the team to discuss the user story than is available during weekly estimation sessions.
All participants are expected to review the user story and associated designs and specifications before the discovery meeting.
@@ -582,7 +672,9 @@ All participants are expected to review the user story and associated designs an
- Software Engineers: Clarifying questions and implementation details
- Product Quality Specialist: Testing plan
+
### Design consultation
+
Design consultations are scheduled as needed with the relevant participants, typically product designers and frontend engineers. It is an opportunity to collaborate and discuss design, implementation, and story requirements. The meeting is scheduled as needed by the product designer or frontend engineer when a user story is in the "Prioritized" column on the [drafting board](https://app.zenhub.com/workspaces/-drafting-ships-in-6-weeks-6192dd66ea2562000faea25c/board).
**Participants:**
@@ -595,7 +687,9 @@ Design consultations are scheduled as needed with the relevant participants, typ
- Discuss design input
- Discuss implementation details
+
### Design reviews
+
Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and contributors proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API.
Product designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. The Head of Product Design and other participants review the changes quickly and give feedback, and then the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business.
@@ -610,12 +704,16 @@ Here are some tips for making this meeting effective:
> To allow for asynchronous participation, instead of attending, contributors can alternatively choose to add an agenda item to the "Product design review" meeting with a GitHub link. Then, the Head of Product Design will review during the meeting and provide feedback. Every "Product design review" is recorded and automatically transcribed to a Google Doc so that it is searchable by every Fleet team member.
+
### Weekly bug review
+
QA has weekly check-in with product to go over the inbox items. QA is responsible for proposing βnot a bugβ, closing due to lack of response (with a nice message), or raising other relevant questions. All requires product agreement
QA may also propose that a reported bug is not actually a bug. A bug is defined as βbehavior that is not according to spec or implied by spec.β If agreed that it is not a bug, then it's assigned to the relevant product manager to determine its priority.
+
### Group weeklies
+
A chance for deeper, synchronous discussion on topics relevant across product groups like βFrontend weeklyβ, βBackend weeklyβ, etc.
**Participants:** Anyone who wishes to participate.
@@ -625,7 +723,9 @@ A chance for deeper, synchronous discussion on topics relevant across product gr
- Review difficult frontend bugs
- Write engineering-initiated stories
+
### Eng Together
+
This meeting is to disseminate engineering-wide announcements, promote cohesion across groups within the engineering team, and connect with engineers (and the "engineering-curious") in other departments. Held monthly for one hour.
**Participants:** Everyone at the company is welcome to attend. All engineers are asked to attend. The subject matter is focused on engineering.
@@ -639,14 +739,18 @@ This meeting is to disseminate engineering-wide announcements, promote cohesion
- Social
- Structured and/or unstructured social activities
+
## Development best practices
+
- Remember the user. What would you do if you saw that error message? [π΄](https://fleetdm.com/handbook/company#empathy)
- Communicate any blockers ASAP in your group Slack channel or standup. [π ](https://fleetdm.com/handbook/company#ownership)
- Think fast and iterate. [π’](https://fleetdm.com/handbook/company#results)
- If it probably works, assume it's still broken. Assume it's your fault. [π΅](https://fleetdm.com/handbook/company#objectivity)
- Speak up and have short toes. Write things down to make them complete. [π£](https://fleetdm.com/handbook/company#openness)
+
## Product design conventions
+
Behind every [wireframe at Fleet](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), there are 3 foundational design principles:
- **Use-case first.** Taking advantage of top-level features vs. per-platform options allows us to take advantage of similarities and avoid having two different ways to configure the same thing.
@@ -657,13 +761,17 @@ Start off cross-platform for every option, setting, and feature. If we **prove**
- **Control the noise.** Bring the needs surface level, tuck away things you don't need by default (when possible, given time). For example, hide Windows controls if there are no Windows devices (based on number of Windows hosts).
+
## Scrum at Fleet
+
Fleet product groups employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence.
New tickets are estimated, specified, and prioritized on the roadmap:
- [Roadmap](https://app.zenhub.com/workspaces/-roadmap-ships-in-6-weeks-6192dd66ea2562000faea25c/board)
+
### Scrum items
+
Our scrum boards are exclusively composed of four types of scrum items:
1. **User stories**: These are simple and concise descriptions of features or requirements from the user's perspective, marked with the `story` label. They keep our focus on delivering value to our customers. Occasionally, due to ZenHub's ticket sub-task structure, the term "epic" may be seen. However, we treat these as regular user stories.
@@ -676,17 +784,23 @@ Our scrum boards are exclusively composed of four types of scrum items:
> Our sprint boards do not accommodate any other type of ticket. By strictly adhering to these four types of scrum items, we maintain an organized and focused workflow that consistently adds value for our users.
+
## Sprints
+
Sprints align with Fleet's [3-week release cycle](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence).
On the first day of each release, all estimated issues are moved into the relevant section of the new "Release" board, which has a kanban view per group.
Sprints are managed in [Zenhub](https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible). To plan capacity for a sprint, [create a "Sprint" issue](https://github.com/fleetdm/confidential/issues/new/choose), replace the fake constants with real numbers, and attach the appropriate labels for your product group.
+
### Sprint numbering
+
Sprints are numbered according to the release version. For example, for the sprint ending on June 30th, 2023, on which date we expect to release Fleet v4.34, the sprint is called the 4.34 sprint.
+
### Sprint ceremonies
+
Each sprint is marked by five essential ceremonies:
1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint.
@@ -695,7 +809,9 @@ Each sprint is marked by five essential ceremonies:
4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review the next release. Engineers are allotted 3-10 minutes to showcase features, improvements, and bug fixes they have contributed to the upcoming release. We focus on changes that can be demoed live and avoid overly technical details so the presentation is accessible to everyone. Features should show what is capable and bugs should identify how this might have impacted existing customers and how this resolution fixed that. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).)
5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint.
+
## Outside contributions
+
[Anyone can contribute](https://fleetdm.com/handbook/company#openness) at Fleet, from inside or outside the company. Since contributors from the wider community don't receive a paycheck from Fleet, they work on whatever they want.
Many open source contributions that start as a small, seemingly innocuous pull request come with lots of additional [unplanned work](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) down the road: unforseen side effects, documentation, testing, potential breaking changes, database migrations, [and more](https://fleetdm.com/handbook/company/development-groups#defining-done).
diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml
index 96fefc9427d9..909daf02e3b8 100644
--- a/handbook/company/testimonials.yml
+++ b/handbook/company/testimonials.yml
@@ -141,15 +141,6 @@
quoteAuthorProfileImageFilename: testimonial-author-dhruv-majumdar-48x48@2x.png
quoteAuthorJobTitle: Director Of Cyber Risk & Advisory
productCategories: [Vulnerability management, Endpoint operations]
--
- quote: When we look at vendors, we look for ones that are very receptive to feedback, where youβre just part of the family, I guess. Fleetβs really good at that.
- quoteImageFilename: logo-deputy-118x28@2x.png
- quoteAuthorName: Harrison Ravazzolo
- quoteAuthorProfileImageFilename: testimonial-author-harrison-ravazzolo-48x48@2x.png
- quoteLinkUrl: https://www.linkedin.com/in/harrison-ravazzolo/
- quoteAuthorJobTitle: Lead platform and identity engineer
- youtubeVideoUrl: https://www.youtube.com/watch?v=5W0q5yQE3R0
- productCategories: [Endpoint operations]
-
quote: Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didnβt expect. It spread so naturally, even our corporate and infrastructure teams want to run it.
quoteAuthorName: Charles Zaffery
diff --git a/handbook/customer-success/customer-success.rituals.yml b/handbook/customer-success/customer-success.rituals.yml
index 017063ae964f..2c493ccf7472 100644
--- a/handbook/customer-success/customer-success.rituals.yml
+++ b/handbook/customer-success/customer-success.rituals.yml
@@ -2,7 +2,7 @@
task: "Prioritize for next sprint" # Title that will actually show in rituals table
startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday
frequency: "Triweekly" # must be supported by
- description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
+ description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table
dri: "zayhanlon" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Enables automation of GitHub issues
diff --git a/handbook/demand/README.md b/handbook/demand/README.md
index d292cfba1f9d..40843ada9b7c 100644
--- a/handbook/demand/README.md
+++ b/handbook/demand/README.md
@@ -58,6 +58,49 @@ To propose an ad, or a change to an ad:
7. Create a calendar reminder to check ad performance two weeks from the date changes were made.
+### Measure intent signals
+
+Intent signals help measure an individual's current level of engagement with the Fleet brand. Use the following steps to decide if:
+
+(a) A contact and/or account needs to be created/updated.
+
+(b) An account should be prioritized for [manual research](https://fleetdm.com/handbook/demand#research-an-account).
+
+(c) An account/contact would benefit from a sales conversation.
+
+(in order of how worthwhile it is to spend time looking at the intent signal)
+
+1. Accounts currently assigned to reps (i.e. pipeline + stage0 + pre-pipeline IQMs).
+2. Accounts with trending psychological progression (as measured by fleetdm.com website signups (i.e. new contacts Β± contacts that have increased their psystage to a certain point).
+3. Accounts that fleeties have suggested to go after in ABM maneuver sheet.
+4. [MacAdmins Slack traffic](https://macadmins.slack.com/archives/C0214NELAE7/p1722561481530559) in the #fleet AND #osquery channels (channel joins, posts, reactions, thread replies, thread reactions).
+5. [LinkedIn page follows](https://www.linkedin.com/company/71111416/admin/analytics/followers/).
+6. [GitHub stars to fleetdm/fleet](https://github.com/fleetdm/fleet/stargazers) from non-fleeties.
+
+
+### Research an account
+
+Follow these steps to research an account and move it toward sales-readiness **after** discovering [relevant intent signals](https://fleetdm.com/handbook/demand#measure-intent-signals).
+
+1. Create the account in SalesForce if it doesn't already exist.
+2. Update any incorrect, mistagged, or incomplete contacts already on the account and merge any duplicates that are found. Verify the following data is current for each existing contact:
+ - "Title"
+ - "Role"
+ - "Primary buying situation"
+ - "LinkedIn"
+ - "Psychological stage"
+ - "intent signals"
+3. If you any reason that the account organization wouldn't benefit from a relationship with Fleet, change the "Type" to "Distraction" stop here. If you haven't disqualified the account at this point, update the "Marketing stage" to "Research-ready".
+After an account is marked "[Research-ready](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000001LerV2AS/view?queryScope=userFolders)".
+
+1. Research missing contacts and add them to salesforce if they are real by using the [ABM maneuvers spreadsheet](https://docs.google.com/spreadsheets/d/1ijtBKTjPg_AodnKEZY0ivia70ttDR3VMURT8rpYwYiw/edit?gid=0#gid=0) to generate a Sales Nav search. Make sure they have "role", "buying situation", "linkedinUrl", "psychological stage", "intent signals" completely filled out and correct.
+2. For "Contact source" for any new contacts, use "Manual research".
+3. Rank the account in terms of closability and fit based on what we see from it and its contacts. Mark any account that is not a fit as "Distraction" instead of "Prospect".
+4. Research and discover mutual connections between fleeties and Mac admin community members within those contacts to help determine fit.
+5. Check Snitcher activity for the account and the psystages of its contacts in Salesforce.
+6. Update the "marketing stage" AND "type" accordingly (qualify or disqualify based on whether the contacts look good). Start running ABM ads on the account if moving it to "Ads running" for a total of 60 days otherwise, stop them if moving it out of "Ads running".
+
+
### Promote a post on LinkedIn
1. Create a classic campaign under ["Experiments"](https://www.linkedin.com/campaignmanager/accounts/509911695/campaigns?campaignGroupIds=%5B678398233%5D) following the YYYY-MM-DD.buying-situation - ad description with a goal of website visits or engagement to run for two weeks.
diff --git a/handbook/demand/demand.rituals.yml b/handbook/demand/demand.rituals.yml
index f64b087da04e..dc1ae27b478c 100644
--- a/handbook/demand/demand.rituals.yml
+++ b/handbook/demand/demand.rituals.yml
@@ -9,7 +9,7 @@
task: "Prioritize for next sprint" # Title that will actually show in rituals table
startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday
frequency: "Triweekly"
- description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
+ description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table
dri: "mikermcneil" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Β« Enable automation of GitHub issues
@@ -22,6 +22,13 @@
description: "https://fleetdm.com/handbook/demand#settle-event-strategy"
moreInfoUrl: "https://fleetdm.com/handbook/demand#settle-event-strategy"
dri: "Drew-P-drawers"
+-
+ task: "π«§ Pipeline sync"
+ startedOn: "2024-08-29"
+ frequency: "Weekly"
+ description: "Allign with CRO and AEs on pipeline processes and incoming leads"
+ moreInfoUrl: ""
+ dri: "Drew-P-drawers"
-
task: "Optimize ads"
startedOn: "2024-02-26"
@@ -50,6 +57,20 @@
description: "Every release cycle, upload the βοΈπ Sprint demos video to YouTube"
moreInfoUrl: "https://fleetdm.com/handbook/demand#upload-to-youtube"
dri: "Drew-P-drawers"
+-
+ task: "Measure intent signals"
+ startedOn: "2024-08-09"
+ frequency: "Daily"
+ description: "Measure intent signals and update SalesForce"
+ moreInfoUrl: "https://fleetdm.com/handbook/demand#measure-intent-signals"
+ dri: "Drew-P-drawers"
+-
+ task: "Research accounts"
+ startedOn: "2024-08-09"
+ frequency: "Daily"
+ description: "Research SalesForce accounts and begin ABM ads"
+ moreInfoUrl: "https://fleetdm.com/handbook/demand#warm-up-actions"
+ dri: "Drew-P-drawers"
# -
# task: "Propose a fleet event"
# startedOn: "2023-10-02"
diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml
index 64224badb179..9e1999d304b2 100644
--- a/handbook/digital-experience/digital-experience.rituals.yml
+++ b/handbook/digital-experience/digital-experience.rituals.yml
@@ -1,5 +1,14 @@
# https://github.com/fleetdm/fleet/pull/13084
-
+-
+ task: "Complete Digital Experience KPIs"
+ startedOn: "2024-08-30"
+ frequency: "Weekly"
+ description: "Complete Digital Experience KPIs for this week"
+ moreInfoUrl: "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0&range=DB1"
+ dri: "SFriendLee"
+ autoIssue:
+ labels: [ "#g-digital-experience" ]
+ repo: "fleet"
-
task: "Prep 1:1s for OKR planning"
startedOn: "2024-09-09"
@@ -65,7 +74,7 @@
task: "Prioritize for next sprint" # Title that will actually show in rituals table
startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday
frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js
- description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)"
+ description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)"
moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table
dri: "sampfluger88" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Enables automation of GitHub issues
@@ -114,8 +123,8 @@
repo: "confidential"
-
task: "Process and backup Sid agenda"
- startedOn: "2023-09-15"
- frequency: "Weekly"
+ startedOn: "2023-09-25"
+ frequency: "Monthly"
description: "Process and backup Sid agenda"
moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#process-and-backup-e-group-agenda"
dri: "SFriendLee"
diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md
index d6f18bde2c82..70d9cd75f46e 100644
--- a/handbook/engineering/README.md
+++ b/handbook/engineering/README.md
@@ -111,11 +111,10 @@ If there is partially merged feature work when the release candidate is created,
Before kicking off release QA, confirm that we are using the latest versions of dependencies we want to keep up-to-date with each release. Currently, those dependencies are:
1. **Go**: Latest minor release
-- Check the [version included in Fleet](https://github.com/fleetdm/fleet/settings/variables/actions).
+- Check the [Go version specified in Fleet's go.mod file](https://github.com/fleetdm/fleet/blob/main/go.mod) (`go 1.XX.YY`).
- Check the [latest minor version of Go](https://go.dev/dl/). For example, if we are using `go1.19.8`, and there is a new minor version `go1.19.9`, we will upgrade.
- If the latest minor version is greater than the version included in Fleet, [file a bug](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. Add the `~release blocker` label. We must upgrade to the latest minor version before publishing the next release.
- If the latest major version is greater than the version included in Fleet, [create a story](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story%2C%3Aproduct&projects=&template=story.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. This will be considered for an upcoming sprint. The release can proceed without upgrading the major version.
-- Note that major version upgrades also require an [update to go.mod](https://github.com/fleetdm/fleet/blob/7b3134498873a31ba748ca27fabb0059cef70db9/go.mod#L3).
> In Go versioning, the number after the first dot is the "major" version, while the number after the second dot is the "minor" version. For example, in Go 1.19.9, "19" is the major version and "9" is the minor version. Major version upgrades are assessed separately by engineering.
diff --git a/handbook/sales/README.md b/handbook/sales/README.md
index ee416ab8da3e..8213a86777c6 100644
--- a/handbook/sales/README.md
+++ b/handbook/sales/README.md
@@ -51,7 +51,9 @@ A recent signed copy of Fleet's W-9 form can be found in [this confidential PDF
For customers with large deployments, Fleet accepts payment via wire transfer or electronic debit (ACH/SWIFT).
-Provide remittance information to customers by exporting ["πΈ Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect.
+Payment information for customers within the United States is on Fleet's invoices. Typically, payment information does not need to be sent separately.
+
+For Fleet customers outside of the United States or instances where a customer is requesting payment information prior to invoicing, provide remittance information to customers by exporting ["πΈ Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect.
### Review rep activity
@@ -59,10 +61,21 @@ Provide remittance information to customers by exporting ["πΈ Paying Fleet"](h
Following up with people interested in Fleet is an important part of finding out whether or not they'd like to continue the process of buying the product. It is also very important not to be annoying. At Fleet, team members follow up with people, but not too often.
To help coach reps and avoid being annoying to Fleet users, Fleet reviews rep activity on a regular basis following these steps:
-1. In Salesforce, visit the activity report on your dashboard. (TODO: taylor will replace this and/or link it)
+1. In Salesforce, visit the activity report on your dashboard.
2. For each rep, review recent activity from the last 30 days across all of that rep's accounts.
3. If outreach is too frequent or doesn't fit the company's strategy, then set up a 30 minute coaching session to discuss with the rep.
+Every week, AEs will review the status of all qualified opportunities with leadership in an opportunity pipeline review meeting. For this meeting, reps will:
+1. Update the following information in Salesforce for every opp:
+ - Contacts (and Roles)
+ - Amount
+ - Close date
+ - Stage
+ - Next steps
+2. Make sure all contacts have been sent a connection request from Mike McNeil.
+3. Identify and discuss where gaps are in [MEDDPICC](https://handbook.gitlab.com/handbook/sales/meddppicc/).
+4. Relay how many meetings they had with attendees from both IT and security this week.
+
### Validate Salesforce data (RevOps)
diff --git a/handbook/sales/sales.rituals.yml b/handbook/sales/sales.rituals.yml
index 6b2fd8fd0d3c..e83a43e85103 100644
--- a/handbook/sales/sales.rituals.yml
+++ b/handbook/sales/sales.rituals.yml
@@ -1,12 +1,18 @@
# https://github.com/fleetdm/fleet/pull/13084
-
+ -
+ task: "Close leads contacted β₯7 days ago"
+ startedOn: "2024-07-05"
+ frequency: "Daily"
+ description: "Close all of your leads in the 'Attempted to contact' stage and which have been there for 7 or more days. If follow-up is appropriate, and won't be bothersome, it can be done after closing the lead. (A new lead can always be opened for the contact later.)"
+ moreInfoUrl: ""
+ dri: "Every AE"
-
task: "Prioritize for next sprint" # Title that will actually show in rituals table
startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday
frequency: "Triweekly" # must be supported by
- description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
+ description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)"
moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table
dri: "alexmitchelliii" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Enables automation of GitHub issues
diff --git a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json
index dabb02d1a674..f4811f765bee 100644
--- a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json
+++ b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json
@@ -2,7 +2,7 @@
"Type": "com.apple.configuration.passcode.settings",
"Identifier": "956e0d14-6019-479b-a6f9-a69ef77668c5",
"Payload": {
- "MaximumFailedAttempts": "five",
+ "MaximumFailedAttempts": 5,
"MaximumInactivityInMinutes ": 5,
"MinimumLength ": 12,
"MinimumComplexCharacters": 3
diff --git a/orbit/TUF.md b/orbit/TUF.md
index 36edaecbd618..727e5f34b60d 100644
--- a/orbit/TUF.md
+++ b/orbit/TUF.md
@@ -7,8 +7,8 @@ Following are the currently deployed versions of fleetd components on the `stabl
| Component\OS | macOS | Linux | Windows | Linux (arm64) |
|--------------|--------------|--------|---------|---------------|
-| orbit | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 |
-| desktop | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 |
+| orbit | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 |
+| desktop | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 |
| osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 |
| nudge | 1.1.10.81462 | - | - | - |
| swiftDialog | 2.1.0 | - | - | - |
diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json
index ed0f67627601..097534dbd1d8 100644
--- a/schema/osquery_fleet_schema.json
+++ b/schema/osquery_fleet_schema.json
@@ -5349,7 +5349,7 @@
},
{
"name": "cryptoinfo",
- "description": "Get info about the a certificate on the host.",
+ "description": "Get info about a certificate on the host.",
"evented": false,
"notes": "This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher).",
"platforms": [
diff --git a/schema/tables/cryptoinfo.yml b/schema/tables/cryptoinfo.yml
index 4fd8d5da374e..38055381b7db 100644
--- a/schema/tables/cryptoinfo.yml
+++ b/schema/tables/cryptoinfo.yml
@@ -1,5 +1,5 @@
name: cryptoinfo
-description: Get info about the a certificate on the host.
+description: Get info about a certificate on the host.
evented: false
notes: This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher).
platforms:
diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go
index d4cf76f3cc09..09a71a6f22fd 100644
--- a/server/datastore/mysql/activities.go
+++ b/server/datastore/mysql/activities.go
@@ -31,7 +31,12 @@ func (ds *Datastore) NewActivity(
var userName *string
var userEmail *string
if user != nil {
- userID = &user.ID
+ // To support creating activities with users that were deleted. This can happen
+ // for automatically installed software which uses the author of the upload as the author of
+ // the installation.
+ if user.ID != 0 {
+ userID = &user.ID
+ }
userName = &user.Name
userEmail = &user.Email
}
@@ -311,10 +316,12 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
// list pending software installs
fmt.Sprintf(`SELECT
hsi.execution_id as uuid,
- u.name as name,
- u.id as user_id,
- u.gravatar_url as gravatar_url,
- u.email as user_email,
+ -- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0),
+ -- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer.
+ IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name,
+ IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id,
+ IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url,
+ IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email,
:installed_software_type as activity_type,
hsi.created_at as created_at,
JSON_OBJECT(
@@ -334,6 +341,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
software_titles st ON st.id = si.title_id
LEFT OUTER JOIN
users u ON u.id = hsi.user_id
+ LEFT OUTER JOIN
+ users u2 ON u2.id = si.user_id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = hsi.host_id
WHERE
diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go
index be87524c05b6..2bf04c06c7a1 100644
--- a/server/datastore/mysql/activities_test.go
+++ b/server/datastore/mysql/activities_test.go
@@ -401,6 +401,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
Title: "foo",
Source: "apps",
Version: "0.0.1",
+ UserID: u.ID,
})
require.NoError(t, err)
sw2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
@@ -411,6 +412,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
Title: "bar",
Source: "apps",
Version: "0.0.2",
+ UserID: u.ID,
})
require.NoError(t, err)
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
@@ -492,7 +494,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
InstallScriptExitCode: ptr.Int(0),
})
require.NoError(t, err)
- h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one
+
+ // No user for this one and not Self-service, means it was installed by Fleet thus the author was decided to be the admin
+ // that uploaded the installer.
+ h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false)
require.NoError(t, err)
// create a single pending request for h2, as well as a non-pending one
@@ -507,6 +512,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
// add a pending software install request for h2
h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false)
require.NoError(t, err)
+ // No user for this one and Self-service, means it was installed by the end user, so the user_id should be null/nil.
+ h2Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, true)
+ require.NoError(t, err)
// nothing for h3
@@ -515,6 +523,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooFailed, h1Bar)
endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h1C, h1D, h1E)
endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooInstalled, h1Foo)
+ endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1Foo)
+ endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Foo)
endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Bar)
endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F)
SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2)
@@ -527,7 +537,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
h1E: false,
h2A: true,
h2F: true,
- h1Foo: false,
+ h1Foo: true,
+ h2Foo: false,
h1Bar: true,
h2Bar: true,
vppCommand1: true,
@@ -542,6 +553,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
h1Foo: "foo",
h1Bar: "bar",
h2Bar: "bar",
+ h2Foo: "foo",
+ }
+ execIDsWithUserAdminID := map[string]struct{}{
+ h1Foo: {},
}
cases := []struct {
@@ -593,10 +608,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
},
{
- opts: fleet.ListOptions{PerPage: 3},
+ opts: fleet.ListOptions{PerPage: 4},
hostID: h2.ID,
- wantExecs: []string{h2Bar, h2A, vppCommand2},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 3},
+ wantExecs: []string{h2Foo, h2Bar, h2A, vppCommand2},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 4},
},
{
opts: fleet.ListOptions{},
@@ -637,7 +652,11 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
case fleet.ActivityTypeInstalledSoftware{}.ActivityName():
require.Equal(t, wantExec, details["install_uuid"], "result %d", i)
require.Equal(t, execIDsSoftwareTitle[wantExec], details["software_title"], "result %d", i)
- wantUser = u2
+ if _, ok := execIDsWithUserAdminID[details["install_uuid"].(string)]; ok {
+ wantUser = u
+ } else {
+ wantUser = u2
+ }
case fleet.ActivityInstalledAppStoreApp{}.ActivityName():
require.Equal(t, wantExec, details["command_uuid"], "result %d", i)
diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go
index 387b59152125..e93c2fe9c0e8 100644
--- a/server/datastore/mysql/apple_mdm.go
+++ b/server/datastore/mysql/apple_mdm.go
@@ -27,6 +27,7 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
+ "github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@@ -88,7 +89,7 @@ INSERT INTO
cp.LabelsExcludeAny[i].Exclude = true
labels = append(labels, cp.LabelsExcludeAny[i])
}
- if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil {
+ if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations")
}
@@ -1135,7 +1136,7 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, server
}
var mdmID int64
- if insertOnDuplicateDidInsert(result) {
+ if insertOnDuplicateDidInsertOrUpdate(result) {
mdmID, _ = result.LastInsertId()
} else {
stmt := `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`
@@ -1444,7 +1445,8 @@ func (ds *Datastore) GetNanoMDMEnrollment(ctx context.Context, id string) (*flee
func (ds *Datastore) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
- return ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles)
+ _, err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles)
+ return err
})
}
@@ -1454,7 +1456,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB(
tx sqlx.ExtContext,
tmID *uint,
profiles []*fleet.MDMAppleConfigProfile,
-) error {
+) (updatedDB bool, err error) {
const loadExistingProfiles = `
SELECT
identifier,
@@ -1516,13 +1518,13 @@ ON DUPLICATE KEY UPDATE
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build query to load existing profiles")
+ return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles")
}
if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "load existing profiles")
+ return false, ctxerr.Wrap(ctx, err, "load existing profiles")
}
}
@@ -1543,31 +1545,37 @@ ON DUPLICATE KEY UPDATE
var (
stmt string
args []interface{}
- err error
)
// delete the obsolete profiles (all those that are not in keepIdents or delivered by Fleet)
+ var result sql.Result
stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, append(keepIdents, fleetIdents...))
if err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "indelete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles")
+ return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles")
}
- if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") {
+ if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "delete obsolete profiles")
+ return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles")
+ }
+ if result != nil {
+ rows, _ := result.RowsAffected()
+ updatedDB = rows > 0
}
// insert the new profiles and the ones that have changed
for _, p := range incomingProfs {
- if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") {
+ if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name,
+ p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier)
+ return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier)
}
+ updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result)
}
// build a list of labels so the associations can be batch-set all at once
@@ -1583,19 +1591,19 @@ ON DUPLICATE KEY UPDATE
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
+ return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
}
if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "reselect") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "load newly inserted profiles")
+ return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles")
}
for _, newlyInsertedProf := range newlyInsertedProfs {
incomingProf, ok := incomingProfs[newlyInsertedProf.Identifier]
if !ok {
- return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier)
+ return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier)
}
for _, label := range incomingProf.LabelsIncludeAll {
@@ -1611,13 +1619,15 @@ ON DUPLICATE KEY UPDATE
}
// insert label associations
- if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") {
+ var updatedLabels bool
+ if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels,
+ "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "inserting apple profile label associations")
+ return false, ctxerr.Wrap(ctx, err, "inserting apple profile label associations")
}
- return nil
+ return updatedDB || updatedLabels, nil
}
func (ds *Datastore) BulkDeleteMDMAppleHostsConfigProfiles(ctx context.Context, profs []*fleet.MDMAppleProfilePayload) error {
@@ -1682,9 +1692,9 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
ctx context.Context,
tx sqlx.ExtContext,
uuids []string,
-) error {
+) (updatedDB bool, err error) {
if len(uuids) == 0 {
- return nil
+ return false, nil
}
appleMDMProfilesDesiredStateQuery := generateDesiredStateQuery("profile")
@@ -1752,13 +1762,14 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
stmt, args, err := sqlx.In(toInstallStmt, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove)
if err != nil {
- return ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, selectProfilesTotalBatches)
+ return false, ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i,
+ selectProfilesTotalBatches)
}
var partialResult []*fleet.MDMAppleProfilePayload
err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...)
if err != nil {
- return ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches)
+ return false, ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches)
}
wantedProfiles = append(wantedProfiles, partialResult...)
@@ -1810,19 +1821,19 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
stmt, args, err := sqlx.In(toRemoveStmt, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove)
if err != nil {
- return ctxerr.Wrap(ctx, err, "building profiles to remove statement")
+ return false, ctxerr.Wrap(ctx, err, "building profiles to remove statement")
}
var partialResult []*fleet.MDMAppleProfilePayload
err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...)
if err != nil {
- return ctxerr.Wrap(ctx, err, "fetching profiles to remove")
+ return false, ctxerr.Wrap(ctx, err, "fetching profiles to remove")
}
currentProfiles = append(currentProfiles, partialResult...)
}
if len(wantedProfiles) == 0 && len(currentProfiles) == 0 {
- return nil
+ return false, nil
}
// delete all host profiles to start from a clean slate, new entries will be added next
@@ -1831,8 +1842,11 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
//
// TODO part II(roberto): we found this call to be a major bottleneck during load testing
// https://github.com/fleetdm/fleet/issues/21338
- if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk delete all profiles")
+ if len(wantedProfiles) > 0 {
+ if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "bulk delete all profiles")
+ }
+ updatedDB = true
}
// profileIntersection tracks profilesToAdd β© profilesToRemove, this is used to avoid:
@@ -1853,11 +1867,57 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
hostProfilesToClean = append(hostProfilesToClean, p)
}
}
- if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk delete profiles to clean")
+ if len(hostProfilesToClean) > 0 {
+ if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to clean")
+ }
+ updatedDB = true
}
+ profilesToInsert := make(map[string]*fleet.MDMAppleProfilePayload)
+
executeUpsertBatch := func(valuePart string, args []any) error {
+ // Check if the update needs to be done at all.
+ selectStmt := fmt.Sprintf(`
+ SELECT
+ host_uuid,
+ profile_uuid,
+ profile_identifier,
+ status,
+ COALESCE(operation_type, '') AS operation_type,
+ COALESCE(detail, '') AS detail,
+ command_uuid,
+ profile_name,
+ checksum,
+ profile_uuid
+ FROM host_mdm_apple_profiles WHERE (host_uuid, profile_uuid) IN (%s)`,
+ strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ","))
+ var selectArgs []any
+ for _, p := range profilesToInsert {
+ selectArgs = append(selectArgs, p.HostUUID, p.ProfileUUID)
+ }
+ var existingProfiles []fleet.MDMAppleProfilePayload
+ if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing")
+ }
+ var updateNeeded bool
+ if len(existingProfiles) == len(profilesToInsert) {
+ for _, exist := range existingProfiles {
+ insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.ProfileUUID)]
+ if !ok || !exist.Equal(*insert) {
+ updateNeeded = true
+ break
+ }
+ }
+ } else {
+ updateNeeded = true
+ }
+ if !updateNeeded {
+ // All profiles are already in the database, no need to update.
+ return nil
+ }
+
+ updatedDB = true
baseStmt := fmt.Sprintf(`
INSERT INTO host_mdm_apple_profiles (
profile_uuid,
@@ -1897,6 +1957,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
resetBatch := func() {
batchCount = 0
+ clear(profilesToInsert)
pargs = pargs[:0]
psb.Reset()
}
@@ -1904,6 +1965,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
for _, p := range wantedProfiles {
if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok {
if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) {
+ profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{
+ ProfileUUID: p.ProfileUUID,
+ ProfileIdentifier: p.ProfileIdentifier,
+ ProfileName: p.ProfileName,
+ HostUUID: p.HostUUID,
+ HostPlatform: p.HostPlatform,
+ Checksum: p.Checksum,
+ Status: pp.Status,
+ OperationType: pp.OperationType,
+ Detail: pp.Detail,
+ CommandUUID: pp.CommandUUID,
+ }
pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum,
pp.OperationType, pp.Status, pp.CommandUUID, pp.Detail)
psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),")
@@ -1911,7 +1984,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
if batchCount >= batchSize {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
resetBatch()
}
@@ -1919,6 +1992,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
}
}
+ profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{
+ ProfileUUID: p.ProfileUUID,
+ ProfileIdentifier: p.ProfileIdentifier,
+ ProfileName: p.ProfileName,
+ HostUUID: p.HostUUID,
+ HostPlatform: p.HostPlatform,
+ Checksum: p.Checksum,
+ OperationType: fleet.MDMOperationTypeInstall,
+ Status: nil,
+ CommandUUID: "",
+ Detail: "",
+ }
pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum,
fleet.MDMOperationTypeInstall, nil, "", "")
psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),")
@@ -1926,7 +2011,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
if batchCount >= batchSize {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
resetBatch()
}
@@ -1943,6 +2028,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
if p.FailedToInstallOnHost() {
continue
}
+ profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{
+ ProfileUUID: p.ProfileUUID,
+ ProfileIdentifier: p.ProfileIdentifier,
+ ProfileName: p.ProfileName,
+ HostUUID: p.HostUUID,
+ HostPlatform: p.HostPlatform,
+ Checksum: p.Checksum,
+ OperationType: fleet.MDMOperationTypeRemove,
+ Status: nil,
+ CommandUUID: "",
+ Detail: "",
+ }
pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum,
fleet.MDMOperationTypeRemove, nil, "", "")
psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),")
@@ -1950,7 +2047,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
if batchCount >= batchSize {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
resetBatch()
}
@@ -1958,10 +2055,10 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
if batchCount > 0 {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
}
- return nil
+ return updatedDB, nil
}
// mdmEntityTypeToDynamicNames tracks what names should be used in the
@@ -3405,7 +3502,7 @@ func (ds *Datastore) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst
// because the updated_at update condition is too complex?), so at the moment
// this clears the profile uuids at all times, even if the profile did not
// change.
- if insertOnDuplicateDidUpdate(res) {
+ if insertOnDuplicateDidInsertOrUpdate(res) {
// profile was updated, need to clear the profile uuids
if err := ds.SetMDMAppleSetupAssistantProfileUUID(ctx, asst.TeamID, "", ""); err != nil {
return nil, ctxerr.Wrap(ctx, err, "clear mdm apple setup assistant profiles")
@@ -3758,9 +3855,9 @@ WHERE
// depCooldownPeriod is the waiting period following a failed DEP assign profile request for a host.
const depCooldownPeriod = 1 * time.Hour // TODO: Make this a test config option?
-func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) {
+func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) {
if len(serials) == 0 {
- return skipSerials, serialsByOrgName, nil
+ return skipSerialsByOrgName, serialsByOrgName, nil
}
stmt := `
@@ -3795,19 +3892,20 @@ WHERE
}
serialsByOrgName = make(map[string][]string)
+ skipSerialsByOrgName = make(map[string][]string)
for _, r := range rows {
switch r.Status {
case "assign":
serialsByOrgName[r.ABMOrgName] = append(serialsByOrgName[r.ABMOrgName], r.HardwareSerial)
case "skip":
- skipSerials = append(skipSerials, r.HardwareSerial)
+ skipSerialsByOrgName[r.ABMOrgName] = append(skipSerialsByOrgName[r.ABMOrgName], r.HardwareSerial)
default:
return nil, nil, ctxerr.New(ctx, fmt.Sprintf("screen dep serials: %s unrecognized status: %s", r.HardwareSerial, r.Status))
}
}
- return skipSerials, serialsByOrgName, nil
+ return skipSerialsByOrgName, serialsByOrgName, nil
}
func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) {
@@ -3954,7 +4052,9 @@ WHERE h.uuid = ?
return nil
}
-func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, incomingDeclarations []*fleet.MDMAppleDeclaration) ([]*fleet.MDMAppleDeclaration, error) {
+func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint,
+ incomingDeclarations []*fleet.MDMAppleDeclaration,
+) (declarations []*fleet.MDMAppleDeclaration, updatedDB bool, err error) {
const insertStmt = `
INSERT INTO mdm_apple_declarations (
declaration_uuid,
@@ -4021,13 +4121,13 @@ WHERE
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return nil, ctxerr.Wrap(ctx, err, "build query to load existing declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "build query to load existing declarations")
}
if err := sqlx.SelectContext(ctx, tx, &existingDecls, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return nil, ctxerr.Wrap(ctx, err, "load existing declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "load existing declarations")
}
}
@@ -4050,23 +4150,29 @@ WHERE
// delete the obsolete declarations (all those that are not in keepNames)
stmt, args, err := sqlx.In(fmt.Sprintf(fmtDeleteStmt, andNameNotInList), declTeamID, keepNames)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles")
+ return nil, false, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles")
}
delStmt = stmt
delArgs = args
}
- if _, err := tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") {
+ var result sql.Result
+ if result, err = tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr,
+ "delete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return nil, ctxerr.Wrap(ctx, err, "delete obsolete declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "delete obsolete declarations")
+ }
+ if result != nil {
+ rows, _ := result.RowsAffected()
+ updatedDB = rows > 0
}
for _, d := range incomingDeclarations {
checksum := md5ChecksumScriptContent(string(d.RawJSON))
declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString()
- if _, err := tx.ExecContext(ctx, insertStmt,
+ if result, err = tx.ExecContext(ctx, insertStmt,
declUUID,
d.Identifier,
d.Name,
@@ -4076,8 +4182,9 @@ WHERE
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return nil, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier)
+ return nil, false, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier)
}
+ updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result)
}
incomingLabels := []fleet.ConfigurationProfileLabel{}
@@ -4092,16 +4199,16 @@ WHERE
// optimization for a later iteration.
stmt, args, err := sqlx.In(loadExistingDecls, declTeamID, incomingNames)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations")
}
if err := sqlx.SelectContext(ctx, tx, &newlyInsertedDecls, stmt, args...); err != nil {
- return nil, ctxerr.Wrap(ctx, err, "load newly inserted declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "load newly inserted declarations")
}
for _, newlyInsertedDecl := range newlyInsertedDecls {
incomingDecl, ok := incomingDecls[newlyInsertedDecl.Name]
if !ok {
- return nil, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name)
+ return nil, false, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name)
}
for _, label := range incomingDecl.LabelsIncludeAll {
@@ -4116,14 +4223,16 @@ WHERE
}
}
- if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") {
+ var updatedLabels bool
+ if updatedLabels, err = batchSetDeclarationLabelAssociationsDB(ctx, tx,
+ incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") {
if err == nil {
err = errors.New(ds.testBatchSetMDMAppleProfilesErr)
}
- return nil, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations")
+ return nil, false, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations")
}
- return incomingDeclarations, nil
+ return incomingDeclarations, updatedDB || updatedLabels, nil
}
func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
@@ -4220,7 +4329,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
declaration.LabelsExcludeAny[i].Exclude = true
labels = append(labels, declaration.LabelsExcludeAny[i])
}
- if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil {
+ if _, err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil {
return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations")
}
@@ -4234,9 +4343,10 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
return declaration, nil
}
-func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, declarationLabels []fleet.ConfigurationProfileLabel) error {
+func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext,
+ declarationLabels []fleet.ConfigurationProfileLabel) (updatedDB bool, err error) {
if len(declarationLabels) == 0 {
- return nil
+ return false, nil
}
// delete any profile+label tuple that is NOT in the list of provided tuples
@@ -4258,38 +4368,72 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
exclude = VALUES(exclude)
`
+ selectStmt := `
+ SELECT apple_declaration_uuid as profile_uuid, label_name, label_id, exclude FROM mdm_declaration_labels
+ WHERE (apple_declaration_uuid, label_name) IN (%s)
+ `
+
var (
- insertBuilder strings.Builder
- deleteBuilder strings.Builder
- insertParams []any
- deleteParams []any
+ insertBuilder strings.Builder
+ selectOrDeleteBuilder strings.Builder
+ selectParams []any
+ insertParams []any
+ deleteParams []any
setProfileUUIDs = make(map[string]struct{})
+ labelsToInsert = make(map[string]*fleet.ConfigurationProfileLabel, len(declarationLabels))
)
for i, pl := range declarationLabels {
+ labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &declarationLabels[i]
if i > 0 {
insertBuilder.WriteString(",")
- deleteBuilder.WriteString(",")
+ selectOrDeleteBuilder.WriteString(",")
}
insertBuilder.WriteString("(?, ?, ?, ?)")
- deleteBuilder.WriteString("(?, ?)")
+ selectOrDeleteBuilder.WriteString("(?, ?)")
+ selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName)
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude)
deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID)
setProfileUUIDs[pl.ProfileUUID] = struct{}{}
}
- _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...)
+ // Determine if we need to update the database
+ var existingProfileLabels []fleet.ConfigurationProfileLabel
+ err = sqlx.SelectContext(ctx, tx, &existingProfileLabels,
+ fmt.Sprintf(selectStmt, selectOrDeleteBuilder.String()), selectParams...)
if err != nil {
- if isChildForeignKeyError(err) {
- // one of the provided labels doesn't exist
- return foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams))
+ return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels")
+ }
+
+ updateNeeded := false
+ if len(existingProfileLabels) == len(labelsToInsert) {
+ for _, existing := range existingProfileLabels {
+ toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)]
+ // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal
+ if !ok || !cmp.Equal(existing, *toInsert) {
+ updateNeeded = true
+ break
+ }
}
+ } else {
+ updateNeeded = true
+ }
+
+ if updateNeeded {
+ _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...)
+ if err != nil {
+ if isChildForeignKeyError(err) {
+ // one of the provided labels doesn't exist
+ return false, foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams))
+ }
- return ctxerr.Wrap(ctx, err, "setting label associations for declarations")
+ return false, ctxerr.Wrap(ctx, err, "setting label associations for declarations")
+ }
+ updatedDB = true
}
- deleteStmt = fmt.Sprintf(deleteStmt, deleteBuilder.String())
+ deleteStmt = fmt.Sprintf(deleteStmt, selectOrDeleteBuilder.String())
profUUIDs := make([]string, 0, len(setProfileUUIDs))
for k := range setProfileUUIDs {
@@ -4299,13 +4443,21 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...)
if err != nil {
- return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations")
+ return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations")
}
- if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil {
- return ctxerr.Wrap(ctx, err, "deleting labels for declarations")
+ var result sql.Result
+ if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "deleting labels for declarations")
+ }
+ if result != nil {
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return false, ctxerr.Wrap(ctx, err, "count rows affected by insert")
+ }
+ updatedDB = updatedDB || rows > 0
}
- return nil
+ return updatedDB, nil
}
func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) {
@@ -4392,23 +4544,24 @@ func (ds *Datastore) MDMAppleBatchSetHostDeclarationState(ctx context.Context) (
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
var err error
- uuids, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending)
+ uuids, _, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending)
return err
})
return uuids, ctxerr.Wrap(ctx, err, "upserting host declaration state")
}
-func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, status *fleet.MDMDeliveryStatus) ([]string, error) {
+func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int,
+ status *fleet.MDMDeliveryStatus) ([]string, bool, error) {
// once all the declarations are in place, compute the desired state
// and find which hosts need a DDM sync.
changedDeclarations, err := mdmAppleGetHostsWithChangedDeclarationsDB(ctx, tx)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "find hosts with changed declarations")
+ return nil, false, ctxerr.Wrap(ctx, err, "find hosts with changed declarations")
}
if len(changedDeclarations) == 0 {
- return []string{}, nil
+ return []string{}, false, nil
}
// a host might have more than one declaration to sync, we do this to
@@ -4430,11 +4583,12 @@ func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtCont
// - support the DDM endpoints, which use data from the
// `host_mdm_apple_declarations` table to compute which declarations to
// serve
- if err := mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil {
- return nil, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations")
+ var updatedDB bool
+ if updatedDB, err = mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil {
+ return nil, false, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations")
}
- return uuids, nil
+ return uuids, updatedDB, nil
}
// mdmAppleBatchSetPendingHostDeclarationsDB tracks the current status of all
@@ -4445,7 +4599,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
batchSize int,
changedDeclarations []*fleet.MDMAppleHostDeclaration,
status *fleet.MDMDeliveryStatus,
-) error {
+) (updatedDB bool, err error) {
baseStmt := `
INSERT INTO host_mdm_apple_declarations
(host_uuid, status, operation_type, checksum, declaration_uuid, declaration_identifier, declaration_name)
@@ -4457,7 +4611,50 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
checksum = VALUES(checksum)
`
+ profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration)
+
executeUpsertBatch := func(valuePart string, args []any) error {
+ // Check if the update needs to be done at all.
+ selectStmt := fmt.Sprintf(`
+ SELECT
+ host_uuid,
+ declaration_uuid,
+ status,
+ COALESCE(operation_type, '') AS operation_type,
+ COALESCE(detail, '') AS detail,
+ checksum,
+ declaration_uuid,
+ declaration_identifier,
+ declaration_name
+ FROM host_mdm_apple_declarations WHERE (host_uuid, declaration_uuid) IN (%s)`,
+ strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ","))
+ var selectArgs []any
+ for _, p := range profilesToInsert {
+ selectArgs = append(selectArgs, p.HostUUID, p.DeclarationUUID)
+ }
+ var existingProfiles []fleet.MDMAppleHostDeclaration
+ if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk set pending declarations select existing")
+ }
+ var updateNeeded bool
+ if len(existingProfiles) == len(profilesToInsert) {
+ for _, exist := range existingProfiles {
+ insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.DeclarationUUID)]
+ if !ok || !exist.Equal(*insert) {
+ updateNeeded = true
+ break
+ }
+ }
+ } else {
+ updateNeeded = true
+ }
+ clear(profilesToInsert)
+ if !updateNeeded {
+ // All profiles are already in the database, no need to update.
+ return nil
+ }
+
+ updatedDB = true
_, err := tx.ExecContext(
ctx,
fmt.Sprintf(baseStmt, strings.TrimSuffix(valuePart, ",")),
@@ -4467,13 +4664,23 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
}
generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) {
+ profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{
+ HostUUID: d.HostUUID,
+ DeclarationUUID: d.DeclarationUUID,
+ Name: d.Name,
+ Identifier: d.Identifier,
+ Status: status,
+ OperationType: d.OperationType,
+ Detail: d.Detail,
+ Checksum: d.Checksum,
+ }
valuePart := "(?, ?, ?, ?, ?, ?, ?),"
args := []any{d.HostUUID, status, d.OperationType, d.Checksum, d.DeclarationUUID, d.Identifier, d.Name}
return valuePart, args
}
- err := batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch)
- return ctxerr.Wrap(ctx, err, "inserting changed host declaration state")
+ err = batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch)
+ return updatedDB, ctxerr.Wrap(ctx, err, "inserting changed host declaration state")
}
// mdmAppleGetHostsWithChangedDeclarationsDB returns a
@@ -4971,6 +5178,7 @@ SELECT
abt.apple_id,
abt.terms_expired,
abt.renew_at,
+ abt.token,
abt.macos_default_team_id,
abt.ios_default_team_id,
abt.ipados_default_team_id,
@@ -5027,6 +5235,15 @@ LEFT OUTER JOIN
tok.IOSTeam = fleet.ABMTokenTeam{Name: tok.IOSTeamName, ID: iOSTeamID}
tok.IPadOSTeam = fleet.ABMTokenTeam{Name: tok.IPadOSTeamName, ID: iPadIOSTeamID}
+ // decrypt the token with the serverPrivateKey, the resulting value will be
+ // the token still encrypted, but just with the ABM cert and key (it is that
+ // encrypted value that is stored with another layer of encryption with the
+ // serverPrivateKey).
+ decrypted, err := decrypt(tok.EncryptedToken, ds.serverPrivateKey)
+ if err != nil {
+ return nil, ctxerr.Wrapf(ctx, err, "decrypting abm token with datastore.serverPrivateKey")
+ }
+ tok.EncryptedToken = decrypted
}
return tokens, nil
diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go
index 389d76e5cb6f..aba31a3c8ebf 100644
--- a/server/datastore/mysql/apple_mdm_test.go
+++ b/server/datastore/mysql/apple_mdm_test.go
@@ -1058,7 +1058,9 @@ func expectAppleDeclarations(
var got []*fleet.MDMAppleDeclaration
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
ctx := context.Background()
- return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_apple_declarations WHERE team_id = ?`, tmID)
+ return sqlx.SelectContext(ctx, q, &got,
+ `SELECT declaration_uuid, team_id, identifier, name, raw_json, checksum, created_at, uploaded_at FROM mdm_apple_declarations WHERE team_id = ?`,
+ tmID)
})
// create map of expected declarations keyed by identifier
@@ -4862,8 +4864,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
Name: "decl-1",
})
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil)
+ updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, "not-exists")
require.NoError(t, err)
@@ -4880,8 +4885,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
nanoEnroll(t, ds, host1, true)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID)
require.NoError(t, err)
@@ -4894,8 +4902,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
Name: "decl-2",
})
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID)
require.NoError(t, err)
@@ -4906,8 +4917,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
err = ds.DeleteMDMAppleConfigProfile(ctx, decl.DeclarationUUID)
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID)
require.NoError(t, err)
@@ -6085,8 +6099,11 @@ func testMDMAppleProfilesOnIOSIPadOS(t *testing.T, ds *Datastore) {
someProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("a", "a", 0))
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil)
+ updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
profiles, err := ds.GetHostMDMAppleProfiles(ctx, "iOS0_UUID")
require.NoError(t, err)
diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go
index af8200b2b05c..21004cf83a08 100644
--- a/server/datastore/mysql/calendar_events.go
+++ b/server/datastore/mysql/calendar_events.go
@@ -64,7 +64,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
return ctxerr.Wrap(ctx, err, "insert calendar event")
}
- if insertOnDuplicateDidInsert(result) {
+ if insertOnDuplicateDidInsertOrUpdate(result) {
id, _ = result.LastInsertId()
} else {
stmt := `SELECT id FROM calendar_events WHERE email = ?`
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index a1008853e365..2edc5ab39353 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -6751,6 +6751,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
InstallScript: "",
PreInstallQuery: "",
Title: "ChocolateRain",
+ UserID: user1.ID,
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false)
diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go
index eefce6091dd5..d3bbba82de31 100644
--- a/server/datastore/mysql/mdm.go
+++ b/server/datastore/mysql/mdm.go
@@ -13,6 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/go-kit/log/level"
+ "github.com/google/go-cmp/cmp"
"github.com/jmoiron/sqlx"
)
@@ -121,22 +122,26 @@ func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, c
return &cmd, nil
}
-func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error {
- return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
- if err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil {
+func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile,
+ winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates,
+ err error) {
+ err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ var err error
+ if updates.WindowsConfigProfile, err = ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil {
return ctxerr.Wrap(ctx, err, "batch set windows profiles")
}
- if err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil {
+ if updates.AppleConfigProfile, err = ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil {
return ctxerr.Wrap(ctx, err, "batch set apple profiles")
}
- if _, err := ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil {
+ if _, updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil {
return ctxerr.Wrap(ctx, err, "batch set apple declarations")
}
return nil
})
+ return updates, err
}
func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) {
@@ -335,10 +340,12 @@ func (ds *Datastore) BulkSetPendingMDMHostProfiles(
ctx context.Context,
hostIDs, teamIDs []uint,
profileUUIDs, hostUUIDs []string,
-) error {
- return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
- return ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs)
+) (updates fleet.MDMProfilesUpdates, err error) {
+ err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ updates, err = ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs)
+ return err
})
+ return updates, err
}
// Note that team ID 0 is used for profiles that apply to hosts in no team
@@ -349,7 +356,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB(
tx sqlx.ExtContext,
hostIDs, teamIDs []uint,
profileUUIDs, hostUUIDs []string,
-) error {
+) (updates fleet.MDMProfilesUpdates, err error) {
var (
countArgs int
macProfUUIDs []string
@@ -384,10 +391,10 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB(
countArgs++
}
if countArgs > 1 {
- return errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided")
+ return updates, errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided")
}
if countArgs == 0 {
- return nil
+ return updates, nil
}
var countProfUUIDs int
@@ -401,7 +408,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB(
countProfUUIDs++
}
if countProfUUIDs > 1 {
- return errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles")
+ return updates, errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles")
}
var (
@@ -471,10 +478,10 @@ WHERE
if len(hosts) == 0 && !hasAppleDecls {
uuidStmt, args, err := sqlx.In(uuidStmt, args...)
if err != nil {
- return ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs")
+ return updates, ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs")
}
if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil {
- return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs")
+ return updates, ctxerr.Wrap(ctx, err, "execute query to load host UUIDs")
}
}
@@ -495,12 +502,14 @@ WHERE
}
}
- if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles")
+ updates.AppleConfigProfile, err = ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts)
+ if err != nil {
+ return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles")
}
- if err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles")
+ updates.WindowsConfigProfile, err = ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts)
+ if err != nil {
+ return updates, ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles")
}
const defaultBatchSize = 1000
@@ -513,11 +522,12 @@ WHERE
// (and my hunch is that we could even do the same for
// profiles) but this could be optimized to use only a provided
// set of host uuids.
- if _, err := mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk set pending apple declarations")
+ _, updates.AppleDeclaration, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil)
+ if err != nil {
+ return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple declarations")
}
- return nil
+ return updates, nil
}
func (ds *Datastore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify, toFail, toRetry []string) error {
@@ -984,9 +994,9 @@ func batchSetProfileLabelAssociationsDB(
tx sqlx.ExtContext,
profileLabels []fleet.ConfigurationProfileLabel,
platform string,
-) error {
+) (updatedDB bool, err error) {
if len(profileLabels) == 0 {
- return nil
+ return false, nil
}
var platformPrefix string
@@ -1001,7 +1011,7 @@ func batchSetProfileLabelAssociationsDB(
case "windows":
platformPrefix = "windows"
default:
- return fmt.Errorf("unsupported platform %s", platform)
+ return false, fmt.Errorf("unsupported platform %s", platform)
}
// delete any profile+label tuple that is NOT in the list of provided tuples
@@ -1023,38 +1033,72 @@ func batchSetProfileLabelAssociationsDB(
exclude = VALUES(exclude)
`
+ selectStmt := `
+ SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels
+ WHERE (%s_profile_uuid, label_name) IN (%s)
+ `
+
var (
- insertBuilder strings.Builder
- deleteBuilder strings.Builder
- insertParams []any
- deleteParams []any
+ insertBuilder strings.Builder
+ selectOrDeleteBuilder strings.Builder
+ selectParams []any
+ insertParams []any
+ deleteParams []any
setProfileUUIDs = make(map[string]struct{})
)
+ labelsToInsert := make(map[string]*fleet.ConfigurationProfileLabel, len(profileLabels))
for i, pl := range profileLabels {
+ labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &profileLabels[i]
if i > 0 {
insertBuilder.WriteString(",")
- deleteBuilder.WriteString(",")
+ selectOrDeleteBuilder.WriteString(",")
}
insertBuilder.WriteString("(?, ?, ?, ?)")
- deleteBuilder.WriteString("(?, ?)")
+ selectOrDeleteBuilder.WriteString("(?, ?)")
+ selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName)
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude)
deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID)
setProfileUUIDs[pl.ProfileUUID] = struct{}{}
}
- _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...)
+ // Determine if we need to update the database
+ var existingProfileLabels []fleet.ConfigurationProfileLabel
+ err = sqlx.SelectContext(ctx, tx, &existingProfileLabels,
+ fmt.Sprintf(selectStmt, platformPrefix, platformPrefix, selectOrDeleteBuilder.String()), selectParams...)
if err != nil {
- if isChildForeignKeyError(err) {
- // one of the provided labels doesn't exist
- return foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams))
+ return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels")
+ }
+
+ updateNeeded := false
+ if len(existingProfileLabels) == len(labelsToInsert) {
+ for _, existing := range existingProfileLabels {
+ toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)]
+ // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal
+ if !ok || !cmp.Equal(existing, *toInsert) {
+ updateNeeded = true
+ break
+ }
}
+ } else {
+ updateNeeded = true
+ }
+
+ if updateNeeded {
+ _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...)
+ if err != nil {
+ if isChildForeignKeyError(err) {
+ // one of the provided labels doesn't exist
+ return false, foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams))
+ }
- return ctxerr.Wrap(ctx, err, "setting label associations for profile")
+ return false, ctxerr.Wrap(ctx, err, "setting label associations for profile")
+ }
+ updatedDB = true
}
- deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, deleteBuilder.String(), platformPrefix)
+ deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, selectOrDeleteBuilder.String(), platformPrefix)
profUUIDs := make([]string, 0, len(setProfileUUIDs))
for k := range setProfileUUIDs {
@@ -1064,13 +1108,18 @@ func batchSetProfileLabelAssociationsDB(
deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...)
if err != nil {
- return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles")
+ return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles")
}
- if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil {
- return ctxerr.Wrap(ctx, err, "deleting labels for profiles")
+ var result sql.Result
+ if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "deleting labels for profiles")
+ }
+ if result != nil {
+ rows, _ := result.RowsAffected()
+ updatedDB = updatedDB || rows > 0
}
- return nil
+ return updatedDB, nil
}
func (ds *Datastore) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) {
diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go
index 2f0d29412114..a8d765bacaa4 100644
--- a/server/datastore/mysql/mdm_test.go
+++ b/server/datastore/mysql/mdm_test.go
@@ -21,6 +21,7 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -358,13 +359,15 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
wantApple []*fleet.MDMAppleConfigProfile,
wantWindows []*fleet.MDMWindowsConfigProfile,
wantAppleDecl []*fleet.MDMAppleDeclaration,
+ wantUpdates fleet.MDMProfilesUpdates,
) {
ctx := context.Background()
- err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet)
+ updates, err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet)
require.NoError(t, err)
expectAppleProfiles(t, ds, tmID, wantApple)
expectWindowsProfiles(t, ds, tmID, wantWindows)
expectAppleDeclarations(t, ds, tmID, wantAppleDecl)
+ assert.Equal(t, wantUpdates, updates)
}
withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile {
@@ -383,7 +386,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
}
// empty set for no team (both Apple and Windows)
- applyAndExpect(nil, nil, nil, nil, nil, nil, nil)
+ applyAndExpect(nil, nil, nil, nil, nil, nil, nil, fleet.MDMProfilesUpdates{})
// single Apple and Windows profile set for a specific team
applyAndExpect(
@@ -398,6 +401,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1),
},
[]*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)},
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
)
// single Apple and Windows profile set for no team
@@ -409,6 +413,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
[]*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")},
[]*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")},
[]*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")},
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
)
// new Apple and Windows profile sets for a specific team
@@ -438,6 +443,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
withTeamIDDecl(declForTest("D1", "D1", "foo"), 1),
withTeamIDDecl(declForTest("D2", "D2", "foo"), 1),
},
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
)
// edited profiles, unchanged profiles, and new profiles for a specific team
@@ -473,6 +479,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
withTeamIDDecl(declForTest("D2", "D2", "foo"), 1),
withTeamIDDecl(declForTest("D3", "D3", "bar"), 1),
},
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
)
// new Apple and Windows profiles to no team
@@ -502,10 +509,43 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
declForTest("D5", "D4", "foo"),
declForTest("D4", "D5", "foo"),
},
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
+ )
+
+ // Apply the same profiles again -- no update should be detected
+ applyAndExpect(
+ []*fleet.MDMAppleConfigProfile{
+ configProfileForTest(t, "N4", "I4", "d"),
+ configProfileForTest(t, "N5", "I5", "e"),
+ },
+ []*fleet.MDMWindowsConfigProfile{
+ windowsConfigProfileForTest(t, "W4", "l4"),
+ windowsConfigProfileForTest(t, "W5", "l5"),
+ },
+ []*fleet.MDMAppleDeclaration{
+ declForTest("D5", "D4", "foo"),
+ declForTest("D4", "D5", "foo"),
+ },
+ nil,
+ []*fleet.MDMAppleConfigProfile{
+ configProfileForTest(t, "N4", "I4", "d"),
+ configProfileForTest(t, "N5", "I5", "e"),
+ },
+ []*fleet.MDMWindowsConfigProfile{
+ windowsConfigProfileForTest(t, "W4", "l4"),
+ windowsConfigProfileForTest(t, "W5", "l5"),
+ },
+ []*fleet.MDMAppleDeclaration{
+ declForTest("D5", "D4", "foo"),
+ declForTest("D4", "D5", "foo"),
+ },
+ fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: false},
)
// Test Case 8: Clear profiles for a specific team
- applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil)
+ applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil,
+ fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true},
+ )
}
func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
@@ -1063,17 +1103,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
}
// bulk set for no target ids, does nothing
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil)
+ updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
+
// bulk set for combination of target ids, not allowed
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil)
+ _, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil)
require.Error(t, err)
// bulk set for all created hosts, no profiles yet so nothing changed
allHosts := append(darwinHosts, unenrolledHost, linuxHost)
allHosts = append(allHosts, windowsHosts...)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.False(t, updates.WindowsConfigProfile)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {},
darwinHosts[1]: {},
@@ -1100,7 +1147,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "G2w", "L2"),
windowsConfigProfileForTest(t, "G3w", "L3"),
}
- err = ds.BatchSetMDMProfiles(
+ updates, err = ds.BatchSetMDMProfiles(
ctx,
nil,
macGlobalProfiles,
@@ -1113,6 +1160,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.Len(t, macGlobalProfiles, 3)
globalProfiles := getProfs(nil)
require.Len(t, globalProfiles, 8)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
// list profiles to install, should result in the global profiles for all
// enrolled hosts
@@ -1132,8 +1182,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.Len(t, toRemoveWindows, 0)
// bulk set for all created hosts, enrolled hosts get the no-team profiles
- err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
@@ -1311,7 +1364,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.Len(t, toRemoveWindows, 3)
// update status of the moved host (team has no profiles)
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(darwinHosts[0], windowsHosts[0]),
nil,
@@ -1319,6 +1372,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
@@ -1482,7 +1538,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.Len(t, toRemoveWindows, 3)
// update status of the moved host via its uuid (team has no profiles)
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
nil,
nil,
@@ -1490,6 +1546,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
[]string{darwinHosts[1].UUID, windowsHosts[1].UUID},
)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
@@ -1620,8 +1679,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "T1.1w", "T1.1"),
windowsConfigProfileForTest(t, "T1.2w", "T1.2"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
tm1Profiles := getProfs(&team1.ID)
require.Len(t, tm1Profiles, 4)
@@ -1644,8 +1706,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.Len(t, toRemoveWindows, 0)
// update status of the affected team
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ assert.True(t, updates.WindowsConfigProfile)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
@@ -1827,15 +1892,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "T1.3w", "T1.3"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
newTm1Profiles := getProfs(&team1.ID)
require.Len(t, newTm1Profiles, 4)
// update status of the affected team
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -1974,6 +2045,13 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
},
})
+ // update again -- nothing should change
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
+ require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+
// re-add tm1Profiles[0] to list of team1 profiles (T1.1 on Apple, T1.2 on Windows)
// NOTE: even though it is the same profile, it's unique DB ID is different because
// it got deleted and re-inserted from the team's profiles, so this is reflected in
@@ -1989,14 +2067,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "T1.3w", "T1.3"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil)
require.NoError(t, err)
newTm1Profiles = getProfs(&team1.ID)
require.Len(t, newTm1Profiles, 6)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// update status of the affected team
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -2154,15 +2238,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
}
// TODO(roberto): add new darwin declarations for this and all subsequent assertions
- err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
newGlobalProfiles := getProfs(nil)
require.Len(t, newGlobalProfiles, 6)
// update status of the affected "no-team"
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[0].UUID, nil))
require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[1].UUID, nil))
@@ -2289,15 +2379,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "G5w", "G5"),
}
- err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
require.NoError(t, err)
newGlobalProfiles = getProfs(nil)
require.Len(t, newGlobalProfiles, 8)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// bulk-set only those affected by the new Apple global profile
newDarwinProfileUUID := newGlobalProfiles[3].ProfileUUID
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -2407,8 +2503,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// bulk-set only those affected by the new Apple global profile
newWindowsProfileUUID := newGlobalProfiles[7].ProfileUUID
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -2531,14 +2630,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "T2.1w", "T2.1"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil)
require.NoError(t, err)
tm2Profiles := getProfs(&team2.ID)
require.Len(t, tm2Profiles, 2)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// update status via tm2 id and the global 0 id to test that custom sql statement
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -2714,7 +2819,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "G7w", "G7", labels[5]),
}
- err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil)
require.NoError(t, err)
newGlobalProfiles = getProfs(nil)
require.Len(t, newGlobalProfiles, 12)
@@ -2723,6 +2828,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
setProfileLabels(t, newGlobalProfiles[5], labels[2])
setProfileLabels(t, newGlobalProfiles[10], labels[3], labels[4])
setProfileLabels(t, newGlobalProfiles[11], labels[5])
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// simulate an entry with some values set to NULL
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -2737,7 +2845,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// do a sync of all hosts, should not change anything as no host is a member
// of the new label-based profiles (indices change due to new Apple and
// Windows profiles)
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(
append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...),
@@ -2746,6 +2854,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -2912,7 +3023,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// do a full sync, the new global hosts get the standard global profiles and
// also the label-based profile that they are a member of
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(
append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...),
@@ -2921,6 +3032,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -3117,7 +3231,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// do a sync of those hosts, they will get the two label-based profiles of their platform
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(darwinHosts[2], windowsHosts[2]),
nil,
@@ -3125,6 +3239,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -3327,7 +3444,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, ds.DeleteLabel(ctx, labels[3].Name))
// sync the affected profiles
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
nil,
nil,
@@ -3335,7 +3452,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
nil,
nil,
@@ -3343,6 +3463,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// nothing changes - broken label-based profiles are simply ignored
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
@@ -3551,7 +3674,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(darwinHosts[2], windowsHosts[2]),
nil,
@@ -3559,6 +3682,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -3756,7 +3882,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
setProfileLabels(t, newGlobalProfiles[4], labels[1])
setProfileLabels(t, newGlobalProfiles[10], labels[4])
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
nil,
nil,
@@ -3764,7 +3890,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
nil,
nil,
@@ -3772,6 +3901,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -3969,18 +4101,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "T2.2w", "T2.2", labels[4], labels[5]),
}
- err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil)
+ updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil)
require.NoError(t, err)
tm2Profiles = getProfs(&team2.ID)
require.Len(t, tm2Profiles, 4)
// TODO(mna): temporary until BatchSetMDMProfiles supports labels
setProfileLabels(t, tm2Profiles[1], labels[1], labels[2])
setProfileLabels(t, tm2Profiles[3], labels[4], labels[5])
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// sync team 2, no changes because no host is a member of the labels (except
// index change due to new profiles)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -4178,8 +4316,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// sync team 2, the label-based profile of team2 is now pending install
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -4388,8 +4529,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// sync team 2, the label-based profile of team2 is left untouched (broken
// profiles are ignored)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -4603,8 +4747,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// sync team 2, the label-based profile of team2 is still left untouched
// because even if the hosts are not members anymore, the profile is broken
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -4808,8 +4955,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// sync team 2, now it sees that the hosts are not members and the profile
// gets removed
- err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -5003,7 +5153,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
})
// sanity-check, a full sync does not change anything
- err = ds.BulkSetPendingMDMHostProfiles(
+ updates, err = ds.BulkSetPendingMDMHostProfiles(
ctx,
hostIDsFromHosts(
append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...),
@@ -5012,6 +5162,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
@@ -5237,8 +5390,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
configProfileForTest(t, "T1.2", "T1.2", "e"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
@@ -5275,8 +5431,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_1"})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5354,8 +5513,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_3"})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5444,8 +5606,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
testLabel4, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_4"})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5524,8 +5689,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
windowsConfigProfileForTest(t, "T5.2", "T5.2"),
}
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
@@ -5562,8 +5730,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_6"})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5641,8 +5812,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5731,8 +5905,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore)
label, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()})
require.NoError(t, err)
- err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
+ updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
var uid string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -5947,18 +6124,16 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
wantOtherWin := []fleet.ConfigurationProfileLabel{
{ProfileUUID: otherWinProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID},
}
- require.NoError(
- t,
- batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"),
- )
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows")
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
// make it an "exclude" label on the other macos profile
wantOtherMac := []fleet.ConfigurationProfileLabel{
{ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true},
}
- require.NoError(
- t,
- batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin"),
- )
+ updatedDB, err = batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin")
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
platforms := map[string]string{
"darwin": macOSProfile.ProfileUUID,
@@ -5991,7 +6166,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
t.Run("empty input "+platform, func(t *testing.T) {
want := []fleet.ConfigurationProfileLabel{}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, want, platform)
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, want, platform)
+ require.NoError(t, err)
+ assert.False(t, updatedDB)
+ return err
})
require.NoError(t, err)
expectLabels(t, uuid, platform, want)
@@ -6005,7 +6183,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
+ return err
})
require.NoError(t, err)
expectLabels(t, uuid, platform, profileLabels)
@@ -6018,7 +6199,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
+ return err
})
require.NoError(t, err)
expectLabels(t, uuid, platform, profileLabels)
@@ -6033,7 +6217,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ return err
})
require.Error(t, err)
})
@@ -6044,7 +6229,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: label.Name, LabelID: 12345},
}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ return err
})
require.Error(t, err)
@@ -6053,7 +6239,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: "xyz", LabelID: 1235},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform)
+ return err
})
require.Error(t, err)
})
@@ -6074,7 +6261,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
+ return err
})
require.NoError(t, err)
// both are stored in the DB
@@ -6085,7 +6275,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
+ require.NoError(t, err)
+ assert.True(t, updatedDB)
+ return err
})
require.NoError(t, err)
expectLabels(t, uuid, platform, profileLabels)
@@ -6098,12 +6291,13 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
t.Run("unsupported platform", func(t *testing.T) {
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
- return batchSetProfileLabelAssociationsDB(
+ _, err := batchSetProfileLabelAssociationsDB(
ctx,
tx,
[]fleet.ConfigurationProfileLabel{{}},
"unsupported",
)
+ return err
})
require.Error(t, err)
})
@@ -6185,7 +6379,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "W2", "l2"),
}
// set the initial profiles without error
- err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil)
+ _, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil)
require.NoError(t, err)
// now ensure all steps are required (add a profile, delete a profile, set labels)
@@ -6201,7 +6395,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) {
ds.testBatchSetMDMAppleProfilesErr = c.appleErr
ds.testBatchSetMDMWindowsProfilesErr = c.windowsErr
- err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil)
+ _, err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil)
require.ErrorContains(t, err, c.wantErr)
})
}
@@ -7139,8 +7333,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
declForTest("D1", "D1", "{}", labels[3], labels[4], labels[5]),
}
- err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls)
+ updates, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
// must reload them to get the profile/declaration uuid
getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload {
@@ -7185,8 +7382,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
// do a sync, they get all platform-specific profiles since they are not part
// of any label
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
@@ -7225,8 +7425,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
@@ -7257,8 +7460,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
+ assert.True(t, updates.AppleConfigProfile)
+ assert.True(t, updates.WindowsConfigProfile)
+ assert.True(t, updates.AppleDeclaration)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
@@ -7293,8 +7499,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
err = ds.DeleteLabel(ctx, labels[3].Name)
require.NoError(t, err)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// broken profiles do not get reported as "to remove"
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
@@ -7345,8 +7554,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
require.NoError(t, err)
nanoEnroll(t, ds, appleHost2, false)
- err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil)
+ updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil)
require.NoError(t, err)
+ assert.False(t, updates.AppleConfigProfile)
+ assert.False(t, updates.WindowsConfigProfile)
+ assert.False(t, updates.AppleDeclaration)
// broken profiles do not get reported as "to install"
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go
index b7426bff009a..ce4d73a9da4f 100644
--- a/server/datastore/mysql/microsoft_mdm.go
+++ b/server/datastore/mysql/microsoft_mdm.go
@@ -1581,7 +1581,7 @@ INSERT INTO
cp.LabelsExcludeAny[i].Exclude = true
labels = append(labels, cp.LabelsExcludeAny[i])
}
- if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil {
+ if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
}
@@ -1653,7 +1653,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB(
tx sqlx.ExtContext,
tmID *uint,
profiles []*fleet.MDMWindowsConfigProfile,
-) error {
+) (updatedDB bool, err error) {
const loadExistingProfiles = `
SELECT
name,
@@ -1721,13 +1721,13 @@ ON DUPLICATE KEY UPDATE
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build query to load existing profiles")
+ return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles")
}
if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "select") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "load existing profiles")
+ return false, ctxerr.Wrap(ctx, err, "load existing profiles")
}
}
@@ -1748,40 +1748,48 @@ ON DUPLICATE KEY UPDATE
var (
stmt string
args []interface{}
- err error
)
// delete the obsolete profiles (all those that are not in keepNames)
+ var result sql.Result
if len(keepNames) > 0 {
stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, keepNames)
if err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "indelete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles")
+ return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles")
}
- if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") {
+ if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr,
+ "delete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "delete obsolete profiles")
+ return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles")
}
} else {
- if _, err := tx.ExecContext(ctx, deleteAllProfilesForTeam, profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") {
+ if result, err = tx.ExecContext(ctx, deleteAllProfilesForTeam,
+ profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "delete all profiles for team")
+ return false, ctxerr.Wrap(ctx, err, "delete all profiles for team")
}
}
+ if result != nil {
+ rows, _ := result.RowsAffected()
+ updatedDB = rows > 0
+ }
// insert the new profiles and the ones that have changed
for _, p := range incomingProfs {
- if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") {
+ if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name,
+ p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name)
+ return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name)
}
+ updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result)
}
// build a list of labels so the associations can be batch-set all at once
@@ -1797,19 +1805,19 @@ ON DUPLICATE KEY UPDATE
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
+ return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
}
if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "reselect") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "load newly inserted profiles")
+ return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles")
}
for _, newlyInsertedProf := range newlyInsertedProfs {
incomingProf, ok := incomingProfs[newlyInsertedProf.Name]
if !ok {
- return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name)
+ return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name)
}
for _, label := range incomingProf.LabelsIncludeAll {
@@ -1825,47 +1833,56 @@ ON DUPLICATE KEY UPDATE
}
// insert/delete the label associations
- if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") {
+ var updatedLabels bool
+ if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels,
+ "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") {
if err == nil {
err = errors.New(ds.testBatchSetMDMWindowsProfilesErr)
}
- return ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
+ return false, ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
}
- return nil
+ return updatedDB || updatedLabels, nil
}
func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB(
ctx context.Context,
tx sqlx.ExtContext,
uuids []string,
-) error {
+) (updatedDB bool, err error) {
if len(uuids) == 0 {
- return nil
+ return false, nil
}
profilesToInstall, err := listMDMWindowsProfilesToInstallDB(ctx, tx, uuids)
if err != nil {
- return ctxerr.Wrap(ctx, err, "list profiles to install")
+ return false, ctxerr.Wrap(ctx, err, "list profiles to install")
}
profilesToRemove, err := listMDMWindowsProfilesToRemoveDB(ctx, tx, uuids)
if err != nil {
- return ctxerr.Wrap(ctx, err, "list profiles to remove")
+ return false, ctxerr.Wrap(ctx, err, "list profiles to remove")
}
if len(profilesToInstall) == 0 && len(profilesToRemove) == 0 {
- return nil
+ return false, nil
}
- if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil {
- return ctxerr.Wrap(ctx, err, "bulk delete profiles to remove")
+ if len(profilesToRemove) > 0 {
+ if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to remove")
+ }
+ updatedDB = true
+ }
+ if len(profilesToInstall) == 0 {
+ return updatedDB, nil
}
var (
- pargs []any
- psb strings.Builder
- batchCount int
+ pargs []any
+ profilesToInsert = make(map[string]*fleet.MDMWindowsProfilePayload)
+ psb strings.Builder
+ batchCount int
)
const defaultBatchSize = 1000
@@ -1877,10 +1894,48 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB(
resetBatch := func() {
batchCount = 0
pargs = pargs[:0]
+ clear(profilesToInsert)
psb.Reset()
}
executeUpsertBatch := func(valuePart string, args []any) error {
+ // Check if the update needs to be done at all.
+ selectStmt := fmt.Sprintf(`
+ SELECT
+ profile_uuid,
+ host_uuid,
+ status,
+ COALESCE(operation_type, '') AS operation_type,
+ COALESCE(detail, '') AS detail,
+ COALESCE(command_uuid, '') AS command_uuid,
+ COALESCE(profile_name, '') AS profile_name
+ FROM host_mdm_windows_profiles WHERE (profile_uuid, host_uuid) IN (%s)`,
+ strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ","))
+ var selectArgs []any
+ for _, p := range profilesToInsert {
+ selectArgs = append(selectArgs, p.ProfileUUID, p.HostUUID)
+ }
+ var existingProfiles []fleet.MDMWindowsProfilePayload
+ if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing")
+ }
+ var updateNeeded bool
+ if len(existingProfiles) == len(profilesToInsert) {
+ for _, exist := range existingProfiles {
+ insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.ProfileUUID, exist.HostUUID)]
+ if !ok || !exist.Equal(*insert) {
+ updateNeeded = true
+ break
+ }
+ }
+ } else {
+ updateNeeded = true
+ }
+ if !updateNeeded {
+ // All profiles are already in the database, no need to update.
+ return nil
+ }
+
baseStmt := fmt.Sprintf(`
INSERT INTO host_mdm_windows_profiles (
profile_uuid,
@@ -1898,11 +1953,25 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB(
detail = ''
`, strings.TrimSuffix(valuePart, ","))
- _, err = tx.ExecContext(ctx, baseStmt, args...)
- return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch")
+ _, err := tx.ExecContext(ctx, baseStmt, args...)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch")
+ }
+ updatedDB = true
+ return nil
}
for _, p := range profilesToInstall {
+ profilesToInsert[fmt.Sprintf("%s\n%s", p.ProfileUUID, p.HostUUID)] = &fleet.MDMWindowsProfilePayload{
+ ProfileUUID: p.ProfileUUID,
+ ProfileName: p.ProfileName,
+ HostUUID: p.HostUUID,
+ Status: nil,
+ OperationType: fleet.MDMOperationTypeInstall,
+ Detail: p.Detail,
+ CommandUUID: p.CommandUUID,
+ Retries: p.Retries,
+ }
pargs = append(
pargs, p.ProfileUUID, p.HostUUID, p.ProfileName,
fleet.MDMOperationTypeInstall)
@@ -1910,7 +1979,7 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB(
batchCount++
if batchCount >= batchSize {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
resetBatch()
}
@@ -1918,11 +1987,11 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB(
if batchCount > 0 {
if err := executeUpsertBatch(psb.String(), pargs); err != nil {
- return err
+ return false, err
}
}
- return nil
+ return updatedDB, nil
}
func (ds *Datastore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) {
diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go
index 02a4c9d149d6..263a6e549102 100644
--- a/server/datastore/mysql/microsoft_mdm_test.go
+++ b/server/datastore/mysql/microsoft_mdm_test.go
@@ -15,6 +15,7 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -1885,7 +1886,7 @@ func testSetOrReplaceMDMWindowsConfigProfile(t *testing.T, ds *Datastore) {
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &prof,
- `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`,
+ `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`,
teamID, name)
})
return &prof
@@ -1983,7 +1984,9 @@ func expectWindowsProfiles(
var got []*fleet.MDMWindowsConfigProfile
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
ctx := context.Background()
- return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tmID)
+ return sqlx.SelectContext(ctx, q, &got,
+ `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`,
+ tmID)
})
// create map of expected profiles keyed by name
@@ -2025,9 +2028,13 @@ func expectWindowsProfiles(
func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background()
- applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile) map[string]string {
+ applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile,
+ wantUpdated bool) map[string]string {
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
- return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet)
+ updatedDB, err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet)
+ require.NoError(t, err)
+ assert.Equal(t, wantUpdated, updatedDB)
+ return err
})
require.NoError(t, err)
return expectWindowsProfiles(t, ds, tmID, want)
@@ -2041,7 +2048,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &prof,
- `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`,
+ `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`,
teamID, name)
})
return &prof
@@ -2057,14 +2064,14 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
}
// apply empty set for no-team
- applyAndExpect(nil, nil, nil)
+ applyAndExpect(nil, nil, nil, false)
// apply single profile set for tm1
mTm1 := applyAndExpect([]*fleet.MDMWindowsConfigProfile{
windowsConfigProfileForTest(t, "N1", "l1"),
}, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{
withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1),
- })
+ }, true)
profTm1N1 := getProfileByTeamAndName(ptr.Uint(1), "N1")
// apply single profile set for no-team
@@ -2072,7 +2079,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
windowsConfigProfileForTest(t, "N1", "l1"),
}, nil, []*fleet.MDMWindowsConfigProfile{
windowsConfigProfileForTest(t, "N1", "l1"),
- })
+ }, true)
// wait a second to ensure timestamps in the DB change
time.Sleep(time.Second)
@@ -2084,7 +2091,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
}, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{
withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), profTm1N1.UploadedAt),
withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1),
- })
+ }, true)
// uuid for N1-I1 is unchanged
require.Equal(t, mTm1["I1"], mTm1b["I1"])
profTm1N2 := getProfileByTeamAndName(ptr.Uint(1), "N2")
@@ -2102,7 +2109,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
withTeamID(windowsConfigProfileForTest(t, "N1", "l1b"), 1),
withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), profTm1N2.UploadedAt),
withTeamID(windowsConfigProfileForTest(t, "N3", "l3"), 1),
- })
+ }, true)
// uuid for N1-I1 is unchanged
require.Equal(t, mTm1b["I1"], mTm1c["I1"])
// uuid for N2-I2 is unchanged
@@ -2119,10 +2126,19 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
}, nil, []*fleet.MDMWindowsConfigProfile{
windowsConfigProfileForTest(t, "N4", "l4"),
windowsConfigProfileForTest(t, "N5", "l5"),
- })
+ }, true)
+
+ // apply the same thing again -- nothing updated
+ applyAndExpect([]*fleet.MDMWindowsConfigProfile{
+ windowsConfigProfileForTest(t, "N4", "l4"),
+ windowsConfigProfileForTest(t, "N5", "l5"),
+ }, nil, []*fleet.MDMWindowsConfigProfile{
+ windowsConfigProfileForTest(t, "N4", "l4"),
+ windowsConfigProfileForTest(t, "N5", "l5"),
+ }, false)
// clear profiles for tm1
- applyAndExpect(nil, ptr.Uint(1), nil)
+ applyAndExpect(nil, ptr.Uint(1), nil, true)
}
// if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise
diff --git a/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go b/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go
new file mode 100644
index 000000000000..3d4beb6fbe88
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240829170024_PolicyAutomaticInstallSoftware.go
@@ -0,0 +1,37 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20240829170024, Down_20240829170024)
+}
+
+func Up_20240829170024(tx *sql.Tx) error {
+ if _, err := tx.Exec(`
+ ALTER TABLE policies
+ ADD COLUMN software_installer_id INT UNSIGNED DEFAULT NULL,
+ ADD FOREIGN KEY fk_policies_software_installer_id (software_installer_id) REFERENCES software_installers (id);
+ `); err != nil {
+ return fmt.Errorf("failed to add software_installer_id to policies: %w", err)
+ }
+
+ // We store `user_name` and `user_email` in case the user is deleted from Fleet (`user_id` set to NULL).
+ if _, err := tx.Exec(`
+ ALTER TABLE software_installers
+ ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL,
+ ADD COLUMN user_name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+ ADD COLUMN user_email VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+ ADD CONSTRAINT fk_software_installers_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
+ `); err != nil {
+ return fmt.Errorf("failed to add user_id to software_installers: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20240829170024(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go
new file mode 100644
index 000000000000..19eed6bee79b
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go
@@ -0,0 +1,40 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20240905105135, Down_20240905105135)
+}
+
+func Up_20240905105135(tx *sql.Tx) error {
+ // The AUTO_INCREMENT columns are used to determine if a row was updated by an INSERT ... ON DUPLICATE KEY UPDATE statement.
+ // This is needed because we are currently using CLIENT_FOUND_ROWS option to determine if a row was found.
+ // And in order to find if the row was updated, we need to check LAST_INSERT_ID().
+ // MySQL docs: https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html
+
+ if !columnExists(tx, "mdm_windows_configuration_profiles", "auto_increment") {
+ if _, err := tx.Exec(`
+ALTER TABLE mdm_windows_configuration_profiles
+ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE
+`); err != nil {
+ return fmt.Errorf("failed to add auto_increment to mdm_windows_configuration_profiles: %w", err)
+ }
+ }
+
+ if !columnExists(tx, "mdm_apple_declarations", "auto_increment") {
+ if _, err := tx.Exec(`
+ALTER TABLE mdm_apple_declarations
+ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE
+`); err != nil {
+ return fmt.Errorf("failed to add auto_increment to mdm_apple_declarations: %w", err)
+ }
+ }
+ return nil
+}
+
+func Down_20240905105135(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go
index 568761b42e82..3acfa46f638e 100644
--- a/server/datastore/mysql/mysql.go
+++ b/server/datastore/mysql/mysql.go
@@ -1242,21 +1242,7 @@ func (ds *Datastore) ProcessList(ctx context.Context) ([]fleet.MySQLProcess, err
return processList, nil
}
-func insertOnDuplicateDidInsert(res sql.Result) bool {
- // Note that connection string sets CLIENT_FOUND_ROWS (see
- // generateMysqlConnectionString in this package), so LastInsertId is 0
- // and RowsAffected 1 when a row is set to its current values.
- //
- // See [the docs][1] or @mna's comment in `insertOnDuplicateDidUpdate`
- // below for more details
- //
- // [1]: https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html
- lastID, _ := res.LastInsertId()
- affected, _ := res.RowsAffected()
- return lastID != 0 && affected == 1
-}
-
-func insertOnDuplicateDidUpdate(res sql.Result) bool {
+func insertOnDuplicateDidInsertOrUpdate(res sql.Result) bool {
// From mysql's documentation:
//
// With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if
@@ -1266,7 +1252,10 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool {
// connecting to mysqld, the affected-rows value is 1 (not 0) if an
// existing row is set to its current values.
//
- // https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html
+ // If a table contains an AUTO_INCREMENT column and INSERT ... ON DUPLICATE KEY UPDATE
+ // inserts or updates a row, the LAST_INSERT_ID() function returns the AUTO_INCREMENT value.
+ //
+ // https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html
//
// Note that connection string sets CLIENT_FOUND_ROWS (see
// generateMysqlConnectionString in this package), so it does return 1 when
@@ -1281,7 +1270,8 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool {
lastID, _ := res.LastInsertId()
aff, _ := res.RowsAffected()
- return lastID == 0 || aff != 1
+ // something was updated (lastID != 0) AND row was found (aff == 1 or higher if more rows were found)
+ return lastID != 0 && aff > 0
}
type parameterizedStmt struct {
diff --git a/server/datastore/mysql/operating_system_vulnerabilities.go b/server/datastore/mysql/operating_system_vulnerabilities.go
index c7598e0464f2..8a6a30dec00c 100644
--- a/server/datastore/mysql/operating_system_vulnerabilities.go
+++ b/server/datastore/mysql/operating_system_vulnerabilities.go
@@ -123,7 +123,7 @@ func (ds *Datastore) InsertOSVulnerability(ctx context.Context, v fleet.OSVulner
return false, ctxerr.Wrap(ctx, err, "insert operating system vulnerability")
}
- return insertOnDuplicateDidInsert(res), nil
+ return insertOnDuplicateDidInsertOrUpdate(res), nil
}
func (ds *Datastore) DeleteOSVulnerabilities(ctx context.Context, vulnerabilities []fleet.OSVulnerability) error {
diff --git a/server/datastore/mysql/operating_system_vulnerabilities_test.go b/server/datastore/mysql/operating_system_vulnerabilities_test.go
index 715d8eada3eb..0b477091cbd7 100644
--- a/server/datastore/mysql/operating_system_vulnerabilities_test.go
+++ b/server/datastore/mysql/operating_system_vulnerabilities_test.go
@@ -7,6 +7,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -233,10 +234,15 @@ func testInsertOSVulnerability(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, didInsert)
- // Inserting the same vulnerability should not insert
- didInsert, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource)
+ // Inserting the same vulnerability should not insert, but update
+ didInsertOrUpdate, err := ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource)
require.NoError(t, err)
- require.Equal(t, false, didInsert)
+ assert.True(t, didInsertOrUpdate)
+
+ // Inserting the exact same vulnerability again should not insert and not update
+ didInsertOrUpdate, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource)
+ require.NoError(t, err)
+ assert.False(t, didInsertOrUpdate)
expected := vulnsUpdate
expected.Source = fleet.MSRCSource
diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go
index 4df1f9324a72..631eb0f9af6d 100644
--- a/server/datastore/mysql/policies.go
+++ b/server/datastore/mysql/policies.go
@@ -22,12 +22,18 @@ import (
const policyCols = `
p.id, p.team_id, p.resolution, p.name, p.query, p.description,
- p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled
+ p.author_id, p.platforms, p.created_at, p.updated_at, p.critical,
+ p.calendar_events_enabled, p.software_installer_id
`
+var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be set on team policies only")
+
var policySearchColumns = []string{"p.name"}
func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) {
+ if args.SoftwareInstallerID != nil {
+ return nil, ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy")
+ }
if args.QueryID != nil {
q, err := ds.Query(ctx, *args.QueryID)
if err != nil {
@@ -129,15 +135,18 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite
//
// Currently, SavePolicy does not allow updating the team of an existing policy.
func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error {
+ if p.TeamID == nil && p.SoftwareInstallerID != nil {
+ return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "save policy")
+ }
// We must normalize the name for full Unicode support (Unicode equivalence).
p.Name = norm.NFC.String(p.Name)
sql := `
UPDATE policies
- SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + `
+ SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, checksum = ` + policiesChecksumComputedColumn() + `
WHERE id = ?
`
result, err := ds.writer(ctx).ExecContext(
- ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID,
+ ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating policy")
@@ -484,7 +493,8 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl
COALESCE(u.email, '') AS author_email,
ps.updated_at as host_count_updated_at,
COALESCE(ps.passing_host_count, 0) as passing_host_count,
- COALESCE(ps.failing_host_count, 0) as failing_host_count
+ COALESCE(ps.failing_host_count, 0) as failing_host_count,
+ p.software_installer_id
FROM policies p
LEFT JOIN users u ON p.author_id = u.id
LEFT JOIN policy_stats ps ON p.id = ps.policy_id
@@ -601,11 +611,11 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u
nameUnicode := norm.NFC.String(args.Name)
res, err := ds.writer(ctx).ExecContext(ctx,
fmt.Sprintf(
- `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`,
+ `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`,
policiesChecksumComputedColumn(),
),
nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical,
- args.CalendarEventsEnabled,
+ args.CalendarEventsEnabled, args.SoftwareInstallerID,
)
switch {
case err == nil:
@@ -792,7 +802,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert")
}
- if insertOnDuplicateDidUpdate(res) {
+ if insertOnDuplicateDidInsertOrUpdate(res) {
// when the upsert results in an UPDATE that *did* change some values,
// it returns the updated ID as last inserted id.
if lastID, _ := res.LastInsertId(); lastID > 0 {
@@ -1429,6 +1439,22 @@ func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fl
return policies, nil
}
+func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) {
+ if len(policyIDs) == 0 {
+ return nil, nil
+ }
+ query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);`
+ query, args, err := sqlx.In(query, teamID, policyIDs)
+ if err != nil {
+ return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer")
+ }
+ var policies []fleet.PolicySoftwareInstallerData
+ if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get policies with associated installer")
+ }
+ return policies, nil
+}
+
func (ds *Datastore) GetTeamHostsPolicyMemberships(
ctx context.Context,
domain string,
diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go
index d3b9bdb93afe..32db698265a7 100644
--- a/server/datastore/mysql/policies_test.go
+++ b/server/datastore/mysql/policies_test.go
@@ -1,6 +1,7 @@
package mysql
import (
+ "bytes"
"context"
"crypto/md5" //nolint:gosec // (only used for tests)
"encoding/hex"
@@ -63,6 +64,8 @@ func TestPolicies(t *testing.T) {
{"TestPoliciesNameSort", testPoliciesNameSort},
{"TestGetCalendarPolicies", testGetCalendarPolicies},
{"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships},
+ {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller},
+ {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -1219,9 +1222,29 @@ func testPolicyQueriesForHost(t *testing.T, ds *Datastore) {
func testPoliciesByID(t *testing.T, ds *Datastore) {
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
policy1 := newTestPolicy(t, ds, user1, "policy1", "darwin", nil)
- _ = newTestPolicy(t, ds, user1, "policy2", "darwin", nil)
+ team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ policy2 := newTestPolicy(t, ds, user1, "policy2", "darwin", &team1.ID)
host1 := newTestHostWithPlatform(t, ds, "host1", "darwin", nil)
+ // Associate an installer to policy2
+ installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "hello",
+ PreInstallQuery: "SELECT 1",
+ PostInstallScript: "world",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage1",
+ Filename: "file1",
+ Title: "file1",
+ Version: "1.0",
+ Source: "apps",
+ UserID: user1.ID,
+ })
+ require.NoError(t, err)
+ policy2.SoftwareInstallerID = ptr.Uint(installerID)
+ err = ds.SavePolicy(context.Background(), policy2, false, false)
+ require.NoError(t, err)
+
require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), host1, map[uint]*bool{policy1.ID: ptr.Bool(true)}, time.Now(), false))
require.NoError(t, ds.UpdateHostPolicyCounts(context.Background()))
@@ -1230,9 +1253,12 @@ func testPoliciesByID(t *testing.T, ds *Datastore) {
assert.Equal(t, len(policiesByID), 2)
assert.Equal(t, policiesByID[1].ID, policy1.ID)
assert.Equal(t, policiesByID[1].Name, policy1.Name)
+ assert.Nil(t, policiesByID[1].SoftwareInstallerID)
+ assert.Equal(t, uint(1), policiesByID[1].PassingHostCount)
assert.Equal(t, policiesByID[2].ID, uint(2))
assert.Equal(t, policiesByID[2].Name, "policy2")
- assert.Equal(t, uint(1), policiesByID[1].PassingHostCount)
+ assert.NotNil(t, policiesByID[2].SoftwareInstallerID)
+ assert.Equal(t, uint(1), *policiesByID[2].SoftwareInstallerID)
_, err = ds.PoliciesByID(context.Background(), []uint{1, 2, 3})
require.Error(t, err)
@@ -3875,3 +3901,106 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial)
require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName)
}
+
+func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) {
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+ _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{
+ Query: "SELECT 1;",
+ SoftwareInstallerID: ptr.Uint(1),
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy)
+}
+
+func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) // team2 has no policies
+ require.NoError(t, err)
+
+ // Policy p1 has no associated installer.
+ p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{
+ Name: "p1",
+ Query: "SELECT 1;",
+ SoftwareInstallerID: nil,
+ })
+ require.NoError(t, err)
+ // Create and associate an installer to p2.
+ installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "hello",
+ PreInstallQuery: "SELECT 1",
+ PostInstallScript: "world",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage1",
+ Filename: "file1",
+ Title: "file1",
+ Version: "1.0",
+ Source: "apps",
+ UserID: user1.ID,
+ })
+ require.NoError(t, err)
+ require.Nil(t, p1.SoftwareInstallerID)
+ p2, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{
+ Name: "p2",
+ Query: "SELECT 1;",
+ SoftwareInstallerID: ptr.Uint(installerID),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, p2.SoftwareInstallerID)
+ require.Equal(t, installerID, *p2.SoftwareInstallerID)
+ // Create p3 as global policy.
+ _, err = ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{
+ Name: "p3",
+ Query: "SELECT 1;",
+ })
+ require.NoError(t, err)
+
+ p2, err = ds.Policy(ctx, p2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, p2.SoftwareInstallerID)
+ require.Equal(t, installerID, *p2.SoftwareInstallerID)
+
+ policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{})
+ require.NoError(t, err)
+ require.Empty(t, policiesWithInstallers)
+
+ // p1 has no associated installers.
+ policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID})
+ require.NoError(t, err)
+ require.Empty(t, policiesWithInstallers)
+
+ policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p2.ID})
+ require.NoError(t, err)
+ require.Len(t, policiesWithInstallers, 1)
+ require.Equal(t, p2.ID, policiesWithInstallers[0].ID)
+ require.Equal(t, installerID, policiesWithInstallers[0].InstallerID)
+
+ // p2 has associated installer but belongs to team1.
+ policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p2.ID})
+ require.NoError(t, err)
+ require.Empty(t, policiesWithInstallers)
+
+ p1.SoftwareInstallerID = ptr.Uint(installerID)
+ err = ds.SavePolicy(ctx, p1, false, false)
+ require.NoError(t, err)
+
+ p2, err = ds.Policy(ctx, p2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, p2.SoftwareInstallerID)
+ require.Equal(t, installerID, *p2.SoftwareInstallerID)
+
+ policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID, p2.ID})
+ require.NoError(t, err)
+ require.Len(t, policiesWithInstallers, 2)
+ require.Equal(t, p1.ID, policiesWithInstallers[0].ID)
+ require.Equal(t, installerID, policiesWithInstallers[0].InstallerID)
+ require.Equal(t, p2.ID, policiesWithInstallers[1].ID)
+ require.Equal(t, installerID, policiesWithInstallers[1].InstallerID)
+
+ policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p1.ID, p2.ID})
+ require.NoError(t, err)
+ require.Empty(t, policiesWithInstallers)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index 0055f0de1d52..10f27e33e2a0 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -810,10 +810,12 @@ CREATE TABLE `mdm_apple_declarations` (
`checksum` binary(16) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`uploaded_at` timestamp NULL DEFAULT NULL,
+ `auto_increment` bigint NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`declaration_uuid`),
UNIQUE KEY `idx_mdm_apple_declaration_team_identifier` (`team_id`,`identifier`),
- UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`),
+ UNIQUE KEY `auto_increment` (`auto_increment`)
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
@@ -994,8 +996,10 @@ CREATE TABLE `mdm_windows_configuration_profiles` (
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`uploaded_at` timestamp NULL DEFAULT NULL,
`profile_uuid` varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+ `auto_increment` bigint NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`profile_uuid`),
- UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`)
+ UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`),
+ UNIQUE KEY `auto_increment` (`auto_increment`)
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -1030,9 +1034,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=307 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=309 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'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,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'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170024,1,'2020-01-01 01:01:01'),(308,20240905105135,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `mobile_device_management_solutions` (
@@ -1380,11 +1384,14 @@ CREATE TABLE `policies` (
`critical` tinyint(1) NOT NULL DEFAULT '0',
`checksum` binary(16) NOT NULL,
`calendar_events_enabled` tinyint unsigned NOT NULL DEFAULT '0',
+ `software_installer_id` int unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_policies_checksum` (`checksum`),
KEY `idx_policies_author_id` (`author_id`),
KEY `idx_policies_team_id` (`team_id`),
+ KEY `fk_policies_software_installer_id` (`software_installer_id`),
CONSTRAINT `policies_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`),
CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1667,6 +1674,9 @@ CREATE TABLE `software_installers` (
`storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`self_service` tinyint(1) NOT NULL DEFAULT '0',
+ `user_id` int unsigned DEFAULT NULL,
+ `user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+ `user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
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`),
@@ -1674,10 +1684,12 @@ CREATE TABLE `software_installers` (
KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`),
KEY `fk_software_installers_team_id` (`team_id`),
KEY `idx_software_installers_platform_title_id` (`platform`,`title_id`),
+ KEY `fk_software_installers_user_id` (`user_id`),
CONSTRAINT `fk_software_installers_install_script_content_id` FOREIGN KEY (`install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT `fk_software_installers_post_install_script_content_id` FOREIGN KEY (`post_install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT `fk_software_installers_team_id` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
- CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
+ CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT `fk_software_installers_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go
index b3ccd430a416..5543a7661d2e 100644
--- a/server/datastore/mysql/scripts_test.go
+++ b/server/datastore/mysql/scripts_test.go
@@ -1138,6 +1138,8 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
s, err := ds.NewScript(ctx, s)
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Bob", "bob@example.com", true)
+
// create a sync script execution
res, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ScriptContents: "echo something_else", SyncRequest: true})
require.NoError(t, err)
@@ -1153,6 +1155,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
Title: "file1",
Version: "1.0",
Source: "apps",
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -1207,6 +1210,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
Title: "file1",
Version: "1.0",
Source: "apps",
+ UserID: user1.ID,
})
require.NoError(t, err)
diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go
index d3c79f7e2598..4826e0cebaeb 100644
--- a/server/datastore/mysql/software.go
+++ b/server/datastore/mysql/software.go
@@ -2020,7 +2020,7 @@ func (ds *Datastore) InsertSoftwareVulnerability(
return false, ctxerr.Wrap(ctx, err, "insert software vulnerability")
}
- return insertOnDuplicateDidInsert(res), nil
+ return insertOnDuplicateDidInsertOrUpdate(res), nil
}
func (ds *Datastore) ListSoftwareVulnerabilitiesByHostIDsSource(
diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go
index 31b6ecb60b8b..a43c3367ad9a 100644
--- a/server/datastore/mysql/software_installers.go
+++ b/server/datastore/mysql/software_installers.go
@@ -117,8 +117,11 @@ INSERT INTO software_installers (
pre_install_query,
post_install_script_content_id,
platform,
- self_service
-) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ self_service,
+ user_id,
+ user_name,
+ user_email
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))`
args := []interface{}{
tid,
@@ -132,6 +135,9 @@ INSERT INTO software_installers (
postInstallScriptID,
payload.Platform,
payload.SelfService,
+ payload.UserID,
+ payload.UserID,
+ payload.UserID,
}
res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...)
@@ -210,7 +216,8 @@ SELECT
si.pre_install_query,
si.post_install_script_content_id,
si.uploaded_at,
- COALESCE(st.name, '') AS software_title
+ COALESCE(st.name, '') AS software_title,
+ si.platform
FROM
software_installers si
LEFT OUTER JOIN software_titles st ON st.id = si.title_id
@@ -277,9 +284,21 @@ WHERE
return &dest, nil
}
+var errDeleteInstallerWithAssociatedPolicy = errors.New("Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.")
+
func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error {
res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id)
if err != nil {
+ if isMySQLForeignKey(err) {
+ // Check if the software installer is referenced by a policy automation.
+ var count int
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM policies WHERE software_installer_id = ?`, id); err != nil {
+ return ctxerr.Wrapf(ctx, err, "getting reference from policies")
+ }
+ if count > 0 {
+ return ctxerr.Wrap(ctx, errDeleteInstallerWithAssociatedPolicy, "delete software installer")
+ }
+ }
return ctxerr.Wrap(ctx, err, "delete software installer")
}
@@ -345,8 +364,11 @@ SELECT
hsi.user_id AS user_id,
hsi.post_install_script_exit_code,
hsi.install_script_exit_code,
- hsi.self_service,
- hsi.host_deleted_at
+ hsi.self_service,
+ hsi.host_deleted_at,
+ si.user_id AS software_installer_user_id,
+ si.user_name AS software_installer_user_name,
+ si.user_email AS software_installer_user_email
FROM
host_software_installs hsi
JOIN software_installers si ON si.id = hsi.software_installer_id
@@ -485,6 +507,41 @@ WHERE
})
}
+func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
+ stmt := fmt.Sprintf(`
+ SELECT execution_id, %s AS status
+ FROM host_software_installs hsi
+ WHERE hsi.id = (
+ SELECT
+ MAX(id)
+ FROM host_software_installs
+ WHERE
+ software_installer_id = :installer_id AND host_id = :host_id
+ GROUP BY
+ host_id, software_installer_id)
+`, softwareInstallerHostStatusNamedQuery("hsi", ""))
+
+ stmt, args, err := sqlx.Named(stmt, map[string]interface{}{
+ "host_id": hostID,
+ "installer_id": installerID,
+ "software_status_installed": fleet.SoftwareInstallerInstalled,
+ "software_status_failed": fleet.SoftwareInstallerFailed,
+ "software_status_pending": fleet.SoftwareInstallerPending,
+ })
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "build named query to get host last install data")
+ }
+
+ var hostLastInstall fleet.HostLastInstallData
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, args...); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get host last install data")
+ }
+ return &hostLastInstall, nil
+}
+
func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error {
if softwareInstallStore == nil {
// no-op in this case, possible if not running with a Premium license
@@ -547,10 +604,14 @@ INSERT INTO software_installers (
post_install_script_content_id,
platform,
self_service,
- title_id
+ title_id,
+ user_id,
+ user_name,
+ user_email
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
- (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '')
+ (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''),
+ ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?)
)
ON DUPLICATE KEY UPDATE
install_script_content_id = VALUES(install_script_content_id),
@@ -560,7 +621,10 @@ ON DUPLICATE KEY UPDATE
version = VALUES(version),
pre_install_query = VALUES(pre_install_query),
platform = VALUES(platform),
- self_service = VALUES(self_service)
+ self_service = VALUES(self_service),
+ user_id = VALUES(user_id),
+ user_name = VALUES(user_name),
+ user_email = VALUES(user_email)
`
// use a team id of 0 if no-team
@@ -634,6 +698,9 @@ ON DUPLICATE KEY UPDATE
installer.SelfService,
installer.Title,
installer.Source,
+ installer.UserID,
+ installer.UserID,
+ installer.UserID,
}
if _, err := tx.ExecContext(ctx, insertNewOrEditedInstaller, args...); err != nil {
diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go
index 2b589708f3cc..0e1a65e0c31d 100644
--- a/server/datastore/mysql/software_installers_test.go
+++ b/server/datastore/mysql/software_installers_test.go
@@ -31,6 +31,8 @@ func TestSoftwareInstallers(t *testing.T) {
{"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers},
{"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID},
{"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers},
+ {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy},
+ {"GetHostLastInstallData", testGetHostLastInstallData},
}
for _, c := range cases {
@@ -46,6 +48,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now())
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
@@ -57,6 +60,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Title: "file1",
Version: "1.0",
Source: "apps",
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -70,6 +74,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Title: "file2",
Version: "2.0",
Source: "apps",
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -84,6 +89,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Version: "3.0",
Source: "apps",
SelfService: true,
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -169,6 +175,8 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
cases := map[string]*uint{
"no team": nil,
"team": &team.ID,
@@ -188,6 +196,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
InstallScript: "echo",
TeamID: teamID,
Filename: "foo.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
@@ -249,6 +258,8 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
require.NoError(t, err)
teamID := team.ID
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
for _, tc := range []struct {
name string
expectedStatus fleet.SoftwareInstallerStatus
@@ -295,6 +306,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
InstallScript: "echo " + tc.name,
TeamID: &teamID,
Filename: swFilename,
+ UserID: user1.ID,
})
require.NoError(t, err)
host, err := ds.NewHost(ctx, &fleet.Host{
@@ -342,6 +354,8 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) {
store, err := filesystem.NewSoftwareInstallerStore(dir)
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
assertExisting := func(want []string) {
dirEnts, err := os.ReadDir(filepath.Join(dir, "software-installers"))
require.NoError(t, err)
@@ -373,6 +387,7 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) {
Filename: "installer0",
Title: "ins0",
Source: "apps",
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -403,6 +418,8 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
// TODO(roberto): perform better assertions, we should have evertything
// to check that the actual values of everything match.
assertSoftware := func(wantTitles []fleet.SoftwareTitle) {
@@ -442,6 +459,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
Version: "1",
PreInstallQuery: "foo",
+ UserID: user1.ID,
}})
require.NoError(t, err)
assertSoftware([]fleet.SoftwareTitle{
@@ -461,6 +479,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
Version: "1",
PreInstallQuery: "select 0 from foo;",
+ UserID: user1.ID,
},
{
InstallScript: "install",
@@ -472,6 +491,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
Version: "2",
PreInstallQuery: "select 1 from bar;",
+ UserID: user1.ID,
},
})
require.NoError(t, err)
@@ -492,6 +512,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
Version: "2",
PreInstallQuery: "select 1 from bar;",
+ UserID: user1.ID,
},
})
require.NoError(t, err)
@@ -509,6 +530,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
ctx := context.Background()
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo",
@@ -518,10 +540,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
PreInstallQuery: "SELECT 1",
TeamID: &team.ID,
Filename: "foo.pkg",
+ Platform: "darwin",
+ UserID: user1.ID,
})
require.NoError(t, err)
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
require.NoError(t, err)
+ require.Equal(t, "darwin", installerMeta.Platform)
metaByTeamAndTitle, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, *installerMeta.TitleID, true)
require.NoError(t, err)
@@ -536,6 +561,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
@@ -554,6 +580,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
const platform = "linux"
// No installers
@@ -573,6 +600,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
Filename: "foo.pkg",
Platform: platform,
SelfService: false,
+ UserID: user1.ID,
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
@@ -591,6 +619,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
Filename: "foo2.pkg",
Platform: platform,
SelfService: true,
+ UserID: user1.ID,
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
@@ -629,6 +658,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
Filename: "foo global.pkg",
Platform: platform,
SelfService: true,
+ UserID: user1.ID,
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil)
@@ -649,3 +679,191 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
require.NoError(t, err)
assert.True(t, hasSelfService)
}
+
+func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ dir := t.TempDir()
+ store, err := filesystem.NewSoftwareInstallerStore(dir)
+ require.NoError(t, err)
+
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
+ // put an installer and save it in the DB
+ ins0 := "installer.pkg"
+ ins0File := bytes.NewReader([]byte("installer0"))
+ err = store.Put(ctx, ins0, ins0File)
+ require.NoError(t, err)
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+
+ softwareInstallerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: "installer.pkg",
+ Title: "ins0",
+ Source: "apps",
+ Platform: "darwin",
+ TeamID: &team1.ID,
+ UserID: user1.ID,
+ })
+ require.NoError(t, err)
+
+ p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{
+ Name: "p1",
+ Query: "SELECT 1;",
+ SoftwareInstallerID: &softwareInstallerID,
+ })
+ require.NoError(t, err)
+
+ err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID)
+ require.Error(t, err)
+ require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy)
+
+ _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID})
+ require.NoError(t, err)
+
+ err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID)
+ require.NoError(t, err)
+}
+
+func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
+ host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithTeamID(team1.ID))
+ host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithTeamID(team1.ID))
+
+ dir := t.TempDir()
+ store, err := filesystem.NewSoftwareInstallerStore(dir)
+ require.NoError(t, err)
+
+ // put an installer and save it in the DB
+ ins0 := "installer.pkg"
+ ins0File := bytes.NewReader([]byte("installer0"))
+ err = store.Put(ctx, ins0, ins0File)
+ require.NoError(t, err)
+
+ softwareInstallerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: "installer.pkg",
+ Title: "ins1",
+ Source: "apps",
+ Platform: "darwin",
+ TeamID: &team1.ID,
+ UserID: user1.ID,
+ })
+ require.NoError(t, err)
+ softwareInstallerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install2",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: "installer2.pkg",
+ Title: "ins2",
+ Source: "apps",
+ Platform: "darwin",
+ TeamID: &team1.ID,
+ UserID: user1.ID,
+ })
+ require.NoError(t, err)
+
+ // No installations on host1 yet.
+ host1LastInstall, err := ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.Nil(t, host1LastInstall)
+
+ // Install installer.pkg on host1.
+ installUUID1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false)
+ require.NoError(t, err)
+ require.NotEmpty(t, installUUID1)
+
+ // Last installation should be pending.
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+
+ // Set result of last installation.
+ err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
+ HostID: host1.ID,
+ InstallUUID: installUUID1,
+
+ InstallScriptExitCode: ptr.Int(0),
+ })
+ require.NoError(t, err)
+
+ // Last installation should be "installed".
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status)
+
+ // Install installer2.pkg on host1.
+ installUUID2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID2, false)
+ require.NoError(t, err)
+ require.NotEmpty(t, installUUID2)
+
+ // Last installation for installer1.pkg should be "installed".
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status)
+ // Last installation for installer2.pkg should be "pending".
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID2)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID2, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+
+ // Perform another installation of installer1.pkg.
+ installUUID3, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false)
+ require.NoError(t, err)
+ require.NotEmpty(t, installUUID3)
+
+ // Last installation for installer1.pkg should be "pending" again.
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID3, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+
+ // Set result of last installer1.pkg installation.
+ err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
+ HostID: host1.ID,
+ InstallUUID: installUUID3,
+
+ InstallScriptExitCode: ptr.Int(1),
+ })
+ require.NoError(t, err)
+
+ // Last installation for installer1.pkg should be "failed".
+ host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, installUUID3, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerFailed, *host1LastInstall.Status)
+
+ // No installations on host2.
+ host2LastInstall, err := ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID1)
+ require.NoError(t, err)
+ require.Nil(t, host2LastInstall)
+ host2LastInstall, err = ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID2)
+ require.NoError(t, err)
+ require.Nil(t, host2LastInstall)
+}
diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go
index e5ff9403ead9..1d034eb42b27 100644
--- a/server/datastore/mysql/software_test.go
+++ b/server/datastore/mysql/software_test.go
@@ -1958,11 +1958,14 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, inserted)
- inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
+ // Sleep so that the updated_at timestamp is guaranteed to be updated.
+ time.Sleep(1 * time.Second)
+ insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID, CVE: "cve-1",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
- require.False(t, inserted)
+ // This will always return true because we always update the timestamp
+ assert.True(t, insertedOrUpdated)
storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource)
require.NoError(t, err)
@@ -2001,9 +2004,12 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, inserted)
- inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource)
+ // Sleep so that the updated_at timestamp is guaranteed to be updated.
+ time.Sleep(1 * time.Second)
+ insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource)
require.NoError(t, err)
- require.False(t, inserted)
+ // This will always return true because we always update the timestamp
+ assert.True(t, insertedOrUpdated)
storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource)
require.NoError(t, err)
@@ -2567,9 +2573,9 @@ func testDeleteOutOfDateVulnerabilities(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// This should update the 'updated_at' timestamp.
- inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource)
+ insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource)
require.NoError(t, err)
- require.False(t, inserted)
+ assert.True(t, insertedOrUpdated)
err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, 2*time.Hour)
require.NoError(t, err)
@@ -3715,26 +3721,36 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
// add VPP apps, one for both no team and team, and two for no-team only.
va1, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, &tm.ID)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
va2, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2",
- BundleIdentifier: "com.app.vpp2"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2",
+ BundleIdentifier: "com.app.vpp2",
+ }, nil)
require.NoError(t, err)
// create vpp3 app that allows self-service
va3, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3",
- BundleIdentifier: "com.app.vpp3"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3",
+ BundleIdentifier: "com.app.vpp3",
+ }, nil)
require.NoError(t, err)
vpp2, vpp3 := va2.AdamID, va3.AdamID
@@ -3927,8 +3943,10 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
- opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name",
- TestSecondaryOrderKey: "source"}}
+ opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{
+ PerPage: 10, IncludeMetadata: true, OrderKey: "name",
+ TestSecondaryOrderKey: "source",
+ }}
user, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("p4ssw0rd.123"),
@@ -4012,24 +4030,31 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
require.NoError(t, err)
expected := map[string]fleet.HostSoftwareWithInstaller{
- byNSV[a1].Name + byNSV[a1].Source: {Name: byNSV[a1].Name, Source: byNSV[a1].Source,
+ byNSV[a1].Name + byNSV[a1].Source: {
+ Name: byNSV[a1].Name, Source: byNSV[a1].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}},
- }},
- byNSV[b].Name + byNSV[b].Source: {Name: byNSV[b].Name, Source: byNSV[b].Source,
+ },
+ },
+ byNSV[b].Name + byNSV[b].Source: {
+ Name: byNSV[b].Name, Source: byNSV[b].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}},
- }},
+ },
+ },
// c1 and c2 are the same software title because they have the same name and source
- byNSV[c1].Name + byNSV[c1].Source: {Name: byNSV[c1].Name, Source: byNSV[c1].Source,
+ byNSV[c1].Name + byNSV[c1].Source: {
+ Name: byNSV[c1].Name, Source: byNSV[c1].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[c1].Version},
{Version: byNSV[c2].Version},
- }},
+ },
+ },
}
compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool,
- expectOmitted ...string) {
+ expectOmitted ...string,
+ ) {
require.Len(t, got, len(expected)-len(expectOmitted))
prev := ""
for _, g := range got {
@@ -4116,33 +4141,47 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
// add VPP apps, one for both no team and team, and three for no-team only.
va1, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, &tm.ID)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
va2, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2",
- BundleIdentifier: "com.app.vpp2"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2",
+ BundleIdentifier: "com.app.vpp2",
+ }, nil)
require.NoError(t, err)
va3, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3",
- BundleIdentifier: "com.app.vpp3"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3",
+ BundleIdentifier: "com.app.vpp3",
+ }, nil)
require.NoError(t, err)
va4, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4",
- BundleIdentifier: "com.app.vpp4"}, nil)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4",
+ BundleIdentifier: "com.app.vpp4",
+ }, nil)
require.NoError(t, err)
vpp2, vpp3, vpp4 := va2.AdamID, va3.AdamID, va4.AdamID
@@ -4384,6 +4423,7 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) {
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
+ UserID: user.ID,
})
require.NoError(t, err)
@@ -4399,8 +4439,10 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) {
// add a VPP app for team 1
vppTm1, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1"}, &team1.ID)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1",
+ }, &team1.ID)
require.NoError(t, err)
// fail to install it on the host
@@ -4489,6 +4531,7 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
+ UserID: user.ID,
})
require.NoError(t, err)
@@ -4504,8 +4547,10 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore
// add a VPP app for team 1
vppTm1, err := ds.InsertVPPAppWithTeam(ctx,
- &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
- BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0"}, &team1.ID)
+ &fleet.VPPApp{
+ VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
+ BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0",
+ }, &team1.ID)
require.NoError(t, err)
// install it on the host
diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go
index f7d27a020099..594ee84247cf 100644
--- a/server/datastore/mysql/software_titles.go
+++ b/server/datastore/mysql/software_titles.go
@@ -268,7 +268,7 @@ SELECT
vap.icon_url as vpp_app_icon_url
FROM software_titles st
LEFT JOIN software_installers si ON si.title_id = st.id AND %s
-LEFT JOIN vpp_apps vap ON vap.title_id = st.id
+LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND %s
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND (%s)
-- placeholder for JOIN on software/software_cve
@@ -286,6 +286,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel
countsJoin := "TRUE"
softwareInstallersJoinCond := "TRUE"
+ vppAppsJoinCond := "TRUE"
vppAppsTeamsJoinCond := "TRUE"
includeVPPAppsAndSoftwareInstallers := "TRUE"
switch {
@@ -304,6 +305,11 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel
vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID)
}
+ if opt.PackagesOnly {
+ vppAppsJoinCond = "FALSE"
+ vppAppsTeamsJoinCond = "FALSE"
+ }
+
additionalWhere := "TRUE"
match := opt.ListOptions.MatchQuery
softwareJoin := ""
@@ -363,7 +369,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel
defaultFilter += ` AND ( si.self_service = 1 OR vat.self_service = 1 ) `
}
- stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter)
+ stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsJoinCond, vppAppsTeamsJoinCond, countsJoin, 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 d0ee221968c6..efdb25da544c 100644
--- a/server/datastore/mysql/software_titles_test.go
+++ b/server/datastore/mysql/software_titles_test.go
@@ -271,6 +271,8 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", Browser: "chrome"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions", Browser: "chrome"},
@@ -303,6 +305,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer1)
@@ -317,6 +320,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false)
@@ -594,6 +598,8 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{host1.ID}))
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
@@ -627,6 +633,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
Filename: "installer1.pkg",
BundleIdentifier: "foo.bar",
TeamID: &team1.ID,
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer1)
@@ -642,6 +649,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
InstallScript: "echo",
Filename: "installer2.pkg",
TeamID: &team2.ID,
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer2)
@@ -856,12 +864,15 @@ func sortTitlesByName(titles []fleet.SoftwareTitleListResult) {
func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
ctx := context.Background()
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
// create a couple software installers not installed on any host
installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer1)
@@ -870,6 +881,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer2)
@@ -955,6 +967,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore) {
ctx := context.Background()
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// create 2 software installers
installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
@@ -962,6 +975,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer1)
@@ -970,6 +984,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer2)
@@ -1140,6 +1155,8 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) {
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
// Create a macOS software foobar installer on "No team".
macOSInstallerNoTeam, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foobar",
@@ -1148,6 +1165,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) {
InstallScript: "echo",
Filename: "foobar.pkg",
TeamID: nil,
+ UserID: user1.ID,
})
require.NoError(t, err)
@@ -1322,6 +1340,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team Foo"})
require.NoError(t, err)
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer1",
@@ -1329,6 +1348,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
InstallScript: "echo",
Filename: "installer1.pkg",
BundleIdentifier: "com.foo.installer1",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer1)
@@ -1339,6 +1359,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
Filename: "installer2.pkg",
TeamID: &tm.ID,
BundleIdentifier: "com.foo.installer2",
+ UserID: user1.ID,
})
require.NoError(t, err)
require.NotZero(t, installer2)
diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go
index c25b4a791c38..c0d1b8b61b17 100644
--- a/server/datastore/mysql/teams.go
+++ b/server/datastore/mysql/teams.go
@@ -106,7 +106,14 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team)
func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
- _, err := tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid)
+ // Delete team policies first, because policies can have associated installers which may be deleted on cascade
+ // before deleting the policies (which are also deleted on cascade).
+ _, err := tx.ExecContext(ctx, `DELETE FROM policies WHERE team_id = ?`, tid)
+ if err != nil {
+ return ctxerr.Wrapf(ctx, err, "deleting policies for team %d", tid)
+ }
+
+ _, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete team %d", tid)
}
diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go
index 0c000712ace9..f5f894f5f3c8 100644
--- a/server/datastore/mysql/vpp.go
+++ b/server/datastore/mysql/vpp.go
@@ -829,6 +829,10 @@ func (ds *Datastore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []u
if len(args) > 0 {
if _, err := tx.ExecContext(ctx, stmtInsertFull, args...); err != nil {
+ if isChildForeignKeyError(err) {
+ return foreignKey("team", fmt.Sprintf("(team_id)=(%v)", values))
+ }
+
return ctxerr.Wrap(ctx, err, "updating vpp token team")
}
}
diff --git a/server/docs/patterns.md b/server/docs/patterns.md
new file mode 100644
index 000000000000..49b97c6ac0ca
--- /dev/null
+++ b/server/docs/patterns.md
@@ -0,0 +1,26 @@
+# Backend patterns
+
+The backend software patterns that we follow in Fleet.
+
+> NOTE: There are always exceptions to the rules, but we try to follow these patterns as much as possible unless a specific use case calls
+> for something else. These should be discussed within the team and documented before merging.
+
+## MySQL
+
+Use high precision for all time fields. Precise timestamps make sure that we can accurately track when records were created and updated,
+keep records in order with a reliable sort, and speed up testing by not having to wait for the time to
+update. [MySQL reference](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-type-syntax.html). [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703).
+Example:
+
+```sql
+CREATE TABLE `sample` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ `updated_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (`id`)
+);
+```
+
+Do not use [goqu](https://github.com/doug-martin/goqu); use MySQL queries directly. Searching for, understanding, and debugging direct MySQL
+queries is easier. If needing to modify an existing `goqu` query, try to rewrite it in
+MySQL. [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703).
diff --git a/server/fleet/app.go b/server/fleet/app.go
index 3812c6d4502b..603ba2e0ab88 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -142,9 +142,9 @@ type MDM struct {
// Deprecated: use AppleBussinessManager instead
DeprecatedAppleBMDefaultTeam string `json:"apple_bm_default_team,omitempty"`
- // AppleBussinessManager defines the associations between ABM tokens
+ // AppleBusinessManager defines the associations between ABM tokens
// and the teams used to assign hosts when they're ingested from ABM.
- AppleBussinessManager optjson.Slice[MDMAppleABMAssignmentInfo] `json:"apple_business_manager"`
+ AppleBusinessManager optjson.Slice[MDMAppleABMAssignmentInfo] `json:"apple_business_manager"`
// AppleBMEnabledAndConfigured is set to true if Fleet has been
// configured with the required Apple BM key pair or token. It can't be set
@@ -635,12 +635,12 @@ func (c *AppConfig) Copy() *AppConfig {
clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings)
}
- if c.MDM.AppleBussinessManager.Set {
- abm := make([]MDMAppleABMAssignmentInfo, len(c.MDM.AppleBussinessManager.Value))
- for i, s := range c.MDM.AppleBussinessManager.Value {
+ if c.MDM.AppleBusinessManager.Set {
+ abm := make([]MDMAppleABMAssignmentInfo, len(c.MDM.AppleBusinessManager.Value))
+ for i, s := range c.MDM.AppleBusinessManager.Value {
abm[i] = s
}
- clone.MDM.AppleBussinessManager = optjson.SetSlice(abm)
+ clone.MDM.AppleBusinessManager = optjson.SetSlice(abm)
}
@@ -878,9 +878,6 @@ func (c AppConfig) MarshalJSON() ([]byte, error) {
if !c.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
c.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
}
- if c.MDM.AppleBussinessManager.Set {
- c.MDM.DeprecatedAppleBMDefaultTeam = ""
- }
type aliasConfig AppConfig
aa := aliasConfig(c)
return json.Marshal(aa)
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index 29424190a4cb..9a3bb005f8d3 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -1,6 +1,7 @@
package fleet
import (
+ "bytes"
"context"
"crypto/md5" // nolint: gosec
"encoding/hex"
@@ -204,6 +205,13 @@ type MDMAppleConfigProfile struct {
UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
}
+// MDMProfilesUpdates flags updates that were done during batch processing of profiles.
+type MDMProfilesUpdates struct {
+ AppleConfigProfile bool
+ WindowsConfigProfile bool
+ AppleDeclaration bool
+}
+
// ConfigurationProfileLabel represents the many-to-many relationship between
// profiles and labels.
//
@@ -309,6 +317,20 @@ func (p *MDMAppleProfilePayload) FailedToInstallOnHost() bool {
return p.Status != nil && *p.Status == MDMDeliveryFailed && p.OperationType == MDMOperationTypeInstall
}
+func (p MDMAppleProfilePayload) Equal(other MDMAppleProfilePayload) bool {
+ statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status
+ return p.ProfileUUID == other.ProfileUUID &&
+ p.ProfileIdentifier == other.ProfileIdentifier &&
+ p.ProfileName == other.ProfileName &&
+ p.HostUUID == other.HostUUID &&
+ p.HostPlatform == other.HostPlatform &&
+ bytes.Equal(p.Checksum, other.Checksum) &&
+ statusEqual &&
+ p.OperationType == other.OperationType &&
+ p.Detail == other.Detail &&
+ p.CommandUUID == other.CommandUUID
+}
+
type MDMAppleBulkUpsertHostProfilePayload struct {
ProfileUUID string
ProfileIdentifier string
@@ -660,6 +682,18 @@ type MDMAppleHostDeclaration struct {
Checksum string `db:"checksum" json:"-"`
}
+func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool {
+ statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status
+ return statusEqual &&
+ p.HostUUID == other.HostUUID &&
+ p.DeclarationUUID == other.DeclarationUUID &&
+ p.Name == other.Name &&
+ p.Identifier == other.Identifier &&
+ p.OperationType == other.OperationType &&
+ p.Detail == other.Detail &&
+ p.Checksum == other.Checksum
+}
+
func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration {
var decl MDMAppleDeclaration
diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go
index 16799f6cc3be..f31796d4a4f0 100644
--- a/server/fleet/apple_mdm_test.go
+++ b/server/fleet/apple_mdm_test.go
@@ -7,18 +7,20 @@ import (
"crypto/x509"
"encoding/json"
"fmt"
+ "reflect"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/google/go-cmp/cmp"
"github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mozilla.org/pkcs7"
-
- "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
)
func TestMDMAppleConfigProfile(t *testing.T) {
@@ -416,3 +418,199 @@ func TestMDMProfileIsWithinGracePeriod(t *testing.T) {
})
}
}
+
+func TestMDMAppleHostDeclarationEqual(t *testing.T) {
+ t.Parallel()
+
+ // This test is intended to ensure that the Equal method on MDMAppleHostDeclaration is updated when new fields are added.
+ // The Equal method is used to identify whether database update is needed.
+
+ items := [...]MDMAppleHostDeclaration{{}, {}}
+
+ numberOfFields := 0
+ for i := 0; i < len(items); i++ {
+ rValue := reflect.ValueOf(&items[i]).Elem()
+ numberOfFields = rValue.NumField()
+ for j := 0; j < numberOfFields; j++ {
+ field := rValue.Field(j)
+ switch field.Kind() {
+ case reflect.String:
+ valueToSet := fmt.Sprintf("test %d", i)
+ field.SetString(valueToSet)
+ case reflect.Int:
+ field.SetInt(int64(i))
+ case reflect.Bool:
+ field.SetBool(i%2 == 0)
+ case reflect.Pointer:
+ field.Set(reflect.New(field.Type().Elem()))
+ default:
+ t.Fatalf("unhandled field type %s", field.Kind())
+ }
+ }
+ }
+
+ status0 := MDMDeliveryStatus("status")
+ status1 := MDMDeliveryStatus("status")
+ items[0].Status = &status0
+ assert.False(t, items[0].Equal(items[1]))
+
+ // Set known fields to be equal
+ fieldsInEqualMethod := 0
+ items[1].HostUUID = items[0].HostUUID
+ fieldsInEqualMethod++
+ items[1].DeclarationUUID = items[0].DeclarationUUID
+ fieldsInEqualMethod++
+ items[1].Name = items[0].Name
+ fieldsInEqualMethod++
+ items[1].Identifier = items[0].Identifier
+ fieldsInEqualMethod++
+ items[1].OperationType = items[0].OperationType
+ fieldsInEqualMethod++
+ items[1].Detail = items[0].Detail
+ fieldsInEqualMethod++
+ items[1].Checksum = items[0].Checksum
+ fieldsInEqualMethod++
+ items[1].Status = &status1
+ fieldsInEqualMethod++
+ assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)")
+ assert.True(t, items[0].Equal(items[1]))
+
+ // Set pointers to nil
+ items[0].Status = nil
+ items[1].Status = nil
+ assert.True(t, items[0].Equal(items[1]))
+
+}
+
+func TestMDMAppleProfilePayloadEqual(t *testing.T) {
+ t.Parallel()
+
+ // This test is intended to ensure that the Equal method on MDMAppleProfilePayload is updated when new fields are added.
+ // The Equal method is used to identify whether database update is needed.
+
+ items := [...]MDMAppleProfilePayload{{}, {}}
+
+ numberOfFields := 0
+ for i := 0; i < len(items); i++ {
+ rValue := reflect.ValueOf(&items[i]).Elem()
+ numberOfFields = rValue.NumField()
+ for j := 0; j < numberOfFields; j++ {
+ field := rValue.Field(j)
+ switch field.Kind() {
+ case reflect.String:
+ valueToSet := fmt.Sprintf("test %d", i)
+ field.SetString(valueToSet)
+ case reflect.Int:
+ field.SetInt(int64(i))
+ case reflect.Bool:
+ field.SetBool(i%2 == 0)
+ case reflect.Pointer:
+ field.Set(reflect.New(field.Type().Elem()))
+ case reflect.Slice:
+ switch field.Type().Elem().Kind() {
+ case reflect.Uint8:
+ valueToSet := []byte("test")
+ field.Set(reflect.ValueOf(valueToSet))
+ default:
+ t.Fatalf("unhandled slice type %s", field.Type().Elem().Kind())
+ }
+ default:
+ t.Fatalf("unhandled field type %s", field.Kind())
+ }
+ }
+ }
+
+ status0 := MDMDeliveryStatus("status")
+ status1 := MDMDeliveryStatus("status")
+ items[0].Status = &status0
+ checksum0 := []byte("checksum")
+ checksum1 := []byte("checksum")
+ items[0].Checksum = checksum0
+ assert.False(t, items[0].Equal(items[1]))
+
+ // Set known fields to be equal
+ fieldsInEqualMethod := 0
+ items[1].ProfileUUID = items[0].ProfileUUID
+ fieldsInEqualMethod++
+ items[1].ProfileIdentifier = items[0].ProfileIdentifier
+ fieldsInEqualMethod++
+ items[1].ProfileName = items[0].ProfileName
+ fieldsInEqualMethod++
+ items[1].HostUUID = items[0].HostUUID
+ fieldsInEqualMethod++
+ items[1].HostPlatform = items[0].HostPlatform
+ fieldsInEqualMethod++
+ items[1].Checksum = checksum1
+ fieldsInEqualMethod++
+ items[1].Status = &status1
+ fieldsInEqualMethod++
+ items[1].OperationType = items[0].OperationType
+ fieldsInEqualMethod++
+ items[1].Detail = items[0].Detail
+ fieldsInEqualMethod++
+ items[1].CommandUUID = items[0].CommandUUID
+ fieldsInEqualMethod++
+ assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleProfilePayload.Equal needs to be updated for new/updated field(s)")
+ assert.True(t, items[0].Equal(items[1]))
+
+ // Set pointers and slices to nil
+ items[0].Status = nil
+ items[1].Status = nil
+ items[0].Checksum = nil
+ items[1].Checksum = nil
+ assert.True(t, items[0].Equal(items[1]))
+
+}
+
+func TestConfigurationProfileLabelEqual(t *testing.T) {
+ t.Parallel()
+
+ // This test is intended to ensure that the cmp.Equal method on ConfigurationProfileLabel is updated when new fields are added.
+ // The cmp.Equal method is used to identify whether database update is needed.
+
+ items := [...]ConfigurationProfileLabel{{}, {}}
+
+ numberOfFields := 0
+ for i := 0; i < len(items); i++ {
+ rValue := reflect.ValueOf(&items[i]).Elem()
+ numberOfFields = rValue.NumField()
+ for j := 0; j < numberOfFields; j++ {
+ field := rValue.Field(j)
+ switch field.Kind() {
+ case reflect.String:
+ valueToSet := fmt.Sprintf("test %d", i)
+ field.SetString(valueToSet)
+ case reflect.Int:
+ field.SetInt(int64(i))
+ case reflect.Uint:
+ field.SetUint(uint64(i))
+ case reflect.Bool:
+ field.SetBool(i%2 == 0)
+ case reflect.Pointer:
+ field.Set(reflect.New(field.Type().Elem()))
+ default:
+ t.Fatalf("unhandled field type %s", field.Kind())
+ }
+ }
+ }
+
+ assert.False(t, cmp.Equal(items[0], items[1]))
+
+ // Set known fields to be equal
+ fieldsInEqualMethod := 0
+ items[1].ProfileUUID = items[0].ProfileUUID
+ fieldsInEqualMethod++
+ items[1].LabelName = items[0].LabelName
+ fieldsInEqualMethod++
+ items[1].LabelID = items[0].LabelID
+ fieldsInEqualMethod++
+ items[1].Broken = items[0].Broken
+ fieldsInEqualMethod++
+ items[1].Exclude = items[0].Exclude
+ fieldsInEqualMethod++
+
+ assert.Equal(t, fieldsInEqualMethod, numberOfFields,
+ "Does cmp.Equal for ConfigurationProfileLabel needs to be updated for new/updated field(s)?")
+ assert.True(t, cmp.Equal(items[0], items[1]))
+
+}
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 1d721390b0c3..119bdc40f0ca 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -531,7 +531,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, selfService bool) (string, error)
+ InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error)
///////////////////////////////////////////////////////////////////////////////
// SoftwareStore
@@ -679,6 +679,8 @@ type Datastore interface {
// and have a calendar event scheduled.
GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint,
hostID *uint) ([]HostPolicyMembershipData, error)
+ // GetPoliciesWithAssociatedInstaller returns team policies that have an associated installer.
+ GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicySoftwareInstallerData, error)
GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error)
// Methods used for async processing of host policy query results.
@@ -1161,7 +1163,9 @@ type Datastore interface {
// remove for each affected host to pending for the provided criteria, which
// may be either a list of hostIDs, teamIDs, profileUUIDs or hostUUIDs (only
// one of those ID types can be provided).
- BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error
+ BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint,
+ profileUUIDs, hostUUIDs []string) (updates MDMProfilesUpdates,
+ err error)
// GetMDMAppleProfilesContents retrieves the XML contents of the
// profiles requested.
@@ -1284,7 +1288,7 @@ type Datastore interface {
// ScreenDEPAssignProfileSerialsForCooldown returns the serials that are still in cooldown and the
// ones that are ready to be assigned a profile. If `screenRetryJobs` is true, it will also skip
// any serials that have a non-zero `retry_job_id`.
- ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error)
+ ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error)
// GetDEPAssignProfileExpiredCooldowns returns the serials of the hosts that have expired
// cooldowns, grouped by team.
GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error)
@@ -1513,7 +1517,8 @@ type Datastore interface {
// BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or
// no team in a single transaction.
- BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, macDeclarations []*MDMAppleDeclaration) error
+ BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile,
+ macDeclarations []*MDMAppleDeclaration) (updates MDMProfilesUpdates, err error)
// NewMDMAppleDeclaration creates and returns a new MDM Apple declaration.
NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error)
@@ -1621,6 +1626,9 @@ type Datastore interface {
// installer execution IDs that have not yet been run for a given host
ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error)
+ // GetHostLastInstallData returns the data for the last installation of a package on a host.
+ GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*HostLastInstallData, error)
+
// MatchOrCreateSoftwareInstaller matches or creates a new software installer.
MatchOrCreateSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (uint, error)
diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go
index 44b422f913a1..69df8e03237c 100644
--- a/server/fleet/mdm.go
+++ b/server/fleet/mdm.go
@@ -3,7 +3,6 @@ package fleet
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"fmt"
"net/url"
@@ -791,48 +790,6 @@ type TeamTuple struct {
Name string `json:"name"`
}
-// ExtractToken extracts the metadata from the token as stored in the database,
-// and returns the raw token that can be used directly with Apple's VPP API. If
-// while extracting the token it notices that the metadata has changed, it will
-// update t and return true as second return value, indicating that it changed
-// and should be saved.
-func (t *VPPTokenDB) ExtractToken() (rawAppleToken string, didUpdateMetadata bool, err error) {
- var vppTokenData VPPTokenData
- if err := json.Unmarshal([]byte(t.Token), &vppTokenData); err != nil {
- return "", false, fmt.Errorf("unmarshaling VPP token data: %w", err)
- }
-
- vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token)
- if err != nil {
- return "", false, fmt.Errorf("decoding raw vpp token data: %w", err)
- }
-
- var vppTokenRaw VPPTokenRaw
- if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil {
- return "", false, fmt.Errorf("unmarshaling raw vpp token data: %w", err)
- }
-
- exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate)
- if err != nil {
- return "", false, fmt.Errorf("parsing vpp token expiration date: %w", err)
- }
-
- if vppTokenData.Location != t.Location {
- t.Location = vppTokenData.Location
- didUpdateMetadata = true
- }
- if vppTokenRaw.OrgName != t.OrgName {
- t.OrgName = vppTokenRaw.OrgName
- didUpdateMetadata = true
- }
- if !exp.Equal(t.RenewDate) {
- t.RenewDate = exp.UTC()
- didUpdateMetadata = true
- }
-
- return vppTokenRaw.Token, didUpdateMetadata, nil
-}
-
type NullTeamType string
const (
diff --git a/server/fleet/mdm_test.go b/server/fleet/mdm_test.go
index 01bc6787753e..256b65be64ec 100644
--- a/server/fleet/mdm_test.go
+++ b/server/fleet/mdm_test.go
@@ -212,7 +212,7 @@ func TestDEPClient(t *testing.T) {
// simulate using a new token, not yet saved in the DB, so we pass the
// token directly in the context
ctx = ctxabm.NewContext(ctx, &nanodep_client.OAuth1Tokens{AccessToken: c.token})
- orgName = "new_abm_token"
+ orgName = apple_mdm.UnsavedABMTokenOrgName
}
res, err := dep.AccountDetail(ctx, orgName)
diff --git a/server/fleet/policies.go b/server/fleet/policies.go
index 6ce5e380973a..daf0505f0316 100644
--- a/server/fleet/policies.go
+++ b/server/fleet/policies.go
@@ -30,8 +30,44 @@ type PolicyPayload struct {
//
// Empty string targets all platforms.
Platform string
- // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies.
+ // CalendarEventsEnabled indicates whether calendar events are enabled for the policy.
+ //
+ // Only applies to team policies.
+ CalendarEventsEnabled bool
+ // SoftwareInstallerID is the ID of the software installer that will be installed if the policy fails.
+ //
+ // Only applies to team policies.
+ SoftwareInstallerID *uint
+}
+
+// NewTeamPolicyPayload holds data for team policy creation.
+//
+// If QueryID is not nil, then Name, Query and Description are ignored
+// (such fields are fetched from the queries table).
+type NewTeamPolicyPayload struct {
+ // QueryID allows creating a policy from an existing query.
+ //
+ // Using QueryID is the old way of creating policies.
+ // Use Query, Name and Description instead.
+ QueryID *uint
+ // Name is the name of the policy (ignored if QueryID != nil).
+ Name string
+ // Query is the policy query (ignored if QueryID != nil).
+ Query string
+ // Critical marks the policy as high impact.
+ Critical bool
+ // Description is the policy description text (ignored if QueryID != nil).
+ Description string
+ // Resolution indicates the steps needed to solve a failing policy.
+ Resolution string
+ // Platform is a comma-separated string to indicate the target platforms.
+ //
+ // Empty string targets all platforms.
+ Platform string
+ // CalendarEventsEnabled indicates whether calendar events are enabled for the policy.
CalendarEventsEnabled bool
+ // SoftwareTitleID is the ID of the software title that will be installed if the policy fails.
+ SoftwareTitleID *uint
}
var (
@@ -109,8 +145,15 @@ type ModifyPolicyPayload struct {
Platform *string `json:"platform"`
// Critical marks the policy as high impact.
Critical *bool `json:"critical" premium:"true"`
- // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies.
+ // CalendarEventsEnabled indicates whether calendar events are enabled for the policy.
+ //
+ // Only applies to team policies.
CalendarEventsEnabled *bool `json:"calendar_events_enabled" premium:"true"`
+ // SoftwareTitleID is the ID of the software title that will be installed if the policy fails.
+ // Value 0 will unset the current installer from the policy.
+ //
+ // Only applies to team policies.
+ SoftwareTitleID *uint `json:"software_title_id" premium:"true"`
}
// Verify verifies the policy payload is valid.
@@ -163,7 +206,8 @@ type PolicyData struct {
// Empty string targets all platforms.
Platform string `json:"platform" db:"platforms"`
- CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"`
+ CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"`
+ SoftwareInstallerID *uint `json:"-" db:"software_installer_id"`
UpdateCreateTimestamps
}
@@ -177,6 +221,14 @@ type Policy struct {
// FailingHostCount is the number of hosts this policy fails on.
FailingHostCount uint `json:"failing_host_count" db:"failing_host_count"`
HostCountUpdatedAt *time.Time `json:"host_count_updated_at" db:"host_count_updated_at"`
+
+ // InstallSoftware is used to trigger installation of a software title
+ // when this policy fails.
+ //
+ // Only applies to team policies.
+ //
+ // This field is populated from PolicyData.SoftwareInstallerID.
+ InstallSoftware *PolicySoftwareTitle `json:"install_software,omitempty"`
}
type PolicyCalendarData struct {
@@ -184,6 +236,11 @@ type PolicyCalendarData struct {
Name string `db:"name" json:"name"`
}
+type PolicySoftwareInstallerData struct {
+ ID uint `db:"id"`
+ InstallerID uint `db:"software_installer_id"`
+}
+
// PolicyLite is a stripped down version of the policy.
type PolicyLite struct {
ID uint `db:"id"`
@@ -232,10 +289,21 @@ type PolicySpec struct {
//
// Empty string targets all platforms.
Platform string `json:"platform,omitempty"`
- // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies.
+ // CalendarEventsEnabled indicates whether calendar events are enabled for the policy.
+ //
+ // Only applies to team policies.
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
}
+// PolicySoftwareTitle contains software title data for policies.
+type PolicySoftwareTitle struct {
+ // SoftwareTitleID is the ID of the title associated to the policy.
+ SoftwareTitleID uint `json:"software_title_id"`
+ // Name is the associated installer title name
+ // (not the package name, but the installed software title).
+ Name string `json:"name"`
+}
+
// Verify verifies the policy data is valid.
func (p PolicySpec) Verify() error {
if err := verifyPolicyName(p.Name); err != nil {
diff --git a/server/fleet/service.go b/server/fleet/service.go
index b81366fcc1bd..023c560eabcf 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -672,7 +672,7 @@ type Service interface {
// /////////////////////////////////////////////////////////////////////////////
// Team Policies
- NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error)
+ NewTeamPolicy(ctx context.Context, teamID uint, p NewTeamPolicyPayload) (*Policy, error)
ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*Policy, err error)
DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error)
ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error)
@@ -930,6 +930,9 @@ type Service interface {
// CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment
CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error)
+ // GetOTAProfile gets the OTA (over-the-air) profile for a given team based on the enroll secret provided.
+ GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error)
+
///////////////////////////////////////////////////////////////////////////////
// CronSchedulesService
diff --git a/server/fleet/software.go b/server/fleet/software.go
index d7492a2d981b..9045d7e37559 100644
--- a/server/fleet/software.go
+++ b/server/fleet/software.go
@@ -227,6 +227,7 @@ type SoftwareTitleListOptions struct {
KnownExploit bool `query:"exploit,optional"`
MinimumCVSS float64 `query:"min_cvss_score,optional"`
MaximumCVSS float64 `query:"max_cvss_score,optional"`
+ PackagesOnly bool `query:"packages_only,optional"`
}
type HostSoftwareTitleListOptions struct {
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index dbc3ea586e32..b8d254eca9cf 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -79,6 +79,8 @@ type SoftwareInstaller struct {
Name string `json:"name" db:"filename"`
// Version is the version of the software package.
Version string `json:"version" db:"version"`
+ // Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs).
+ Platform string `json:"platform" db:"platform"`
// UploadedAt is the time the software package was uploaded.
UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"`
// InstallerID is the unique identifier for the software package metadata in Fleet.
@@ -140,6 +142,14 @@ func (s SoftwareInstallerStatus) IsValid() bool {
}
}
+// HostLastInstallData contains data for the last installation of a package on a host.
+type HostLastInstallData struct {
+ // ExecutionID is the installation ID of the package on the host.
+ ExecutionID string `db:"execution_id"`
+ // Status is the status of the installation on the host.
+ Status *SoftwareInstallerStatus `db:"status"`
+}
+
// HostSoftwareInstaller represents a software installer package that has been installed on a host.
type HostSoftwareInstallerResult struct {
// ID is the unique numerical ID of the result assigned by the datastore.
@@ -183,6 +193,12 @@ type HostSoftwareInstallerResult struct {
// HostDeletedAt indicates if the data is associated with a
// deleted host
HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"`
+ // SoftwareInstallerUserID is the ID of the user that uploaded the software installer.
+ SoftwareInstallerUserID *uint `json:"-" db:"software_installer_user_id"`
+ // SoftwareInstallerUserID is the name of the user that uploaded the software installer.
+ SoftwareInstallerUserName string `json:"-" db:"software_installer_user_name"`
+ // SoftwareInstallerUserEmail is the email of the user that uploaded the software installer.
+ SoftwareInstallerUserEmail string `json:"-" db:"software_installer_user_email"`
}
const (
@@ -262,6 +278,7 @@ type UploadSoftwareInstallerPayload struct {
Platform string
BundleIdentifier string
SelfService bool
+ UserID uint
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go
index a9fa8ae4519f..8c5dafa15a09 100644
--- a/server/fleet/vpp.go
+++ b/server/fleet/vpp.go
@@ -32,8 +32,11 @@ type VPPApp struct {
Name string `db:"name" json:"name"`
// LatestVersion is the latest version of this app.
LatestVersion string `db:"latest_version" json:"latest_version"`
- TeamID *uint `db:"-" json:"-"`
- TitleID uint `db:"title_id" json:"-"`
+ // TeamID is used for authorization, it must be json serialized to be available
+ // to the rego script. We don't set it outside authorization anyway, so it
+ // won't render otherwise.
+ TeamID *uint `db:"-" json:"team_id,omitempty"`
+ TitleID uint `db:"title_id" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go
index 448776500891..acc90b3a51ac 100644
--- a/server/fleet/windows_mdm.go
+++ b/server/fleet/windows_mdm.go
@@ -158,6 +158,18 @@ type MDMWindowsProfilePayload struct {
Retries int `db:"retries"`
}
+func (p MDMWindowsProfilePayload) Equal(other MDMWindowsProfilePayload) bool {
+ statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status
+ return statusEqual &&
+ p.ProfileUUID == other.ProfileUUID &&
+ p.HostUUID == other.HostUUID &&
+ p.ProfileName == other.ProfileName &&
+ p.OperationType == other.OperationType &&
+ p.Detail == other.Detail &&
+ p.CommandUUID == other.CommandUUID &&
+ p.Retries == other.Retries
+}
+
type MDMWindowsBulkUpsertHostProfilePayload struct {
ProfileUUID string
ProfileName string
diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go
index d85377bb209b..4925202507de 100644
--- a/server/mdm/apple/apple_mdm.go
+++ b/server/mdm/apple/apple_mdm.go
@@ -153,7 +153,7 @@ func (d *DEPService) createDefaultAutomaticProfile(ctx context.Context) error {
//
// On success, it returns the profile uuid and timestamp for the specific token
// of interest to the caller (identified by its organization name).
-func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokeOrgName string) (string, time.Time, error) {
+func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokenOrgName string) (string, time.Time, error) {
appCfg, err := d.ds.AppConfig(ctx)
if err != nil {
return "", time.Time{}, ctxerr.Wrap(ctx, err, "fetching app config")
@@ -249,7 +249,7 @@ func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team
return "", time.Time{}, ctxerr.Wrap(ctx, err, "save default setup assistant profile UUID")
}
}
- if orgName == abmTokeOrgName {
+ if orgName == abmTokenOrgName {
requestedTokenProfileUUID = res.ProfileUUID
}
}
@@ -403,7 +403,7 @@ func (d *DEPService) RunAssigner(ctx context.Context) error {
}
if cursor != "" && effectiveProfModTime.After(cursorModTime) {
- d.logger.Log("msg", "clearing device syncer cursor", "org_name", token.OrganizationName, "team", team.Name)
+ d.logger.Log("msg", "clearing device syncer cursor", "org_name", token.OrganizationName)
if err := d.depStorage.StoreCursor(ctx, token.OrganizationName, ""); err != nil {
result = multierror.Append(result, err)
continue
@@ -624,7 +624,6 @@ func (d *DEPService) processDeviceResponse(
}
logger := kitlog.With(d.logger, "profile_uuid", profUUID)
- level.Info(logger).Log("msg", "calling DEP client to assign profile", "profile_uuid", profUUID)
skipSerials, assignSerials, err := d.ds.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials)
if err != nil {
@@ -643,12 +642,14 @@ func (d *DEPService) processDeviceResponse(
for orgName, serials := range assignSerials {
apiResp, err := d.depClient.AssignProfile(ctx, orgName, profUUID, serials...)
if err != nil {
+ // only log the error so the failure can be recorded
+ // below in UpdateHostDEPAssignProfileResponses and
+ // the proper cooldowns are applied
level.Error(logger).Log(
"msg", "assign profile",
"devices", len(assignSerials),
"err", err,
)
- return fmt.Errorf("assign profile: %w", err)
}
logs := []interface{}{
@@ -1041,3 +1042,36 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
}
return nil
}
+
+func GenerateOTAEnrollmentProfileMobileconfig(orgName, fleetURL, enrollSecret string) ([]byte, error) {
+ path, err := url.JoinPath(fleetURL, "/api/v1/fleet/ota_enrollment")
+ if err != nil {
+ return nil, fmt.Errorf("creating path for ota enrollment url: %w", err)
+ }
+
+ enrollURL, err := url.Parse(path)
+ if err != nil {
+ return nil, fmt.Errorf("parsing ota enrollment url: %w", err)
+ }
+
+ q := enrollURL.Query()
+ q.Set("enroll_secret", enrollSecret)
+ enrollURL.RawQuery = q.Encode()
+
+ var profileBuf bytes.Buffer
+ tmplArgs := struct {
+ Organization string
+ URL string
+ EnrollSecret string
+ }{
+ Organization: orgName,
+ URL: enrollURL.String(),
+ }
+
+ err = mobileconfig.OTAMobileConfigTemplate.Execute(&profileBuf, tmplArgs)
+ if err != nil {
+ return nil, fmt.Errorf("executing ota profile template: %w", err)
+ }
+
+ return profileBuf.Bytes(), nil
+}
diff --git a/server/mdm/apple/apple_mdm_external_test.go b/server/mdm/apple/apple_mdm_external_test.go
index 1867b9357771..30287b0a32c6 100644
--- a/server/mdm/apple/apple_mdm_external_test.go
+++ b/server/mdm/apple/apple_mdm_external_test.go
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -21,11 +22,10 @@ import (
)
func TestDEPService_RunAssigner(t *testing.T) {
- // FIXME
- t.Skip()
ctx := context.Background()
ds := mysql.CreateMySQLDS(t)
+ const abmTokenOrgName = "test_org"
depStorage, err := ds.NewMDMAppleDEPStorage()
require.NoError(t, err)
@@ -35,10 +35,10 @@ func TestDEPService_RunAssigner(t *testing.T) {
t.Cleanup(srv.Close)
t.Cleanup(func() { mysql.TruncateTables(t, ds) })
- err = depStorage.StoreConfig(ctx, "fleet", &nanodep_client.Config{BaseURL: srv.URL})
+ err = depStorage.StoreConfig(ctx, abmTokenOrgName, &nanodep_client.Config{BaseURL: srv.URL})
require.NoError(t, err)
- mysql.SetTestABMAssets(t, ds, "fleet")
+ mysql.SetTestABMAssets(t, ds, abmTokenOrgName)
logger := log.NewNopLogger()
return apple_mdm.NewDEPService(ds, depStorage, logger)
@@ -54,7 +54,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
- _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`))
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName)))
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
require.NoError(t, err)
@@ -78,7 +78,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
require.NotEmpty(t, defProf.Token)
// a profile UUID was assigned for no-team
- profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "")
+ profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName)
require.NoError(t, err)
require.Equal(t, "profile123", profUUID)
require.False(t, modTime.Before(start))
@@ -87,6 +87,11 @@ func TestDEPService_RunAssigner(t *testing.T) {
appCfg, err := ds.AppConfig(ctx)
require.NoError(t, err)
require.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam)
+ abmTok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName)
+ require.NoError(t, err)
+ require.Nil(t, abmTok.MacOSDefaultTeamID)
+ require.Nil(t, abmTok.IPadOSDefaultTeamID)
+ require.Nil(t, abmTok.IOSDefaultTeamID)
// no teams, so no team-specific custom setup assistants
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{})
@@ -120,7 +125,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
- _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`))
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName)))
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})
require.NoError(t, err)
@@ -158,7 +163,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
require.NotEmpty(t, defProf.Token)
// a profile UUID was assigned to no-team
- profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "")
+ profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName)
require.NoError(t, err)
require.Equal(t, "profile123", profUUID)
require.False(t, modTime.Before(start))
@@ -192,7 +197,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
- _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`))
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName)))
case "/profile":
reqBody, err := io.ReadAll(r.Body)
require.NoError(t, err)
@@ -236,12 +241,11 @@ func TestDEPService_RunAssigner(t *testing.T) {
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test_team"})
require.NoError(t, err)
- appCfg, err := ds.AppConfig(ctx)
+ // set that team as default assignment for new macOS devices
+ tok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName)
require.NoError(t, err)
-
- // set that team as default assignment for new devices
- appCfg.MDM.DeprecatedAppleBMDefaultTeam = tm.Name
- err = ds.SaveAppConfig(ctx, appCfg)
+ tok.MacOSDefaultTeamID = &tm.ID
+ err = ds.SaveABMToken(ctx, tok)
require.NoError(t, err)
// create a custom setup assistant for that team
@@ -264,7 +268,7 @@ func TestDEPService_RunAssigner(t *testing.T) {
require.NotEmpty(t, defProf.Token)
// a profile UUID was assigned to the team
- profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "")
+ profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, abmTokenOrgName)
require.NoError(t, err)
require.Equal(t, "profile123", profUUID)
require.False(t, modTime.Before(start))
@@ -272,8 +276,11 @@ func TestDEPService_RunAssigner(t *testing.T) {
// the team-specific custom profile was registered
tmAsst, err = ds.GetMDMAppleSetupAssistant(ctx, tmAsst.TeamID)
require.NoError(t, err)
- //require.Equal(t, "profile456", tmAsst.ProfileUUID)
require.False(t, tmAsst.UploadedAt.Before(start))
+ profileUUID, modTime, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, abmTokenOrgName)
+ require.NoError(t, err)
+ require.Equal(t, "profile456", profileUUID)
+ require.True(t, tmAsst.UploadedAt.Equal(modTime))
// a couple hosts were created and assigned to the team (except the op_type ignored)
hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{})
diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go
index b1f4075f504d..156736ecd4e5 100644
--- a/server/mdm/apple/apple_mdm_test.go
+++ b/server/mdm/apple/apple_mdm_test.go
@@ -21,8 +21,6 @@ import (
)
func TestDEPService(t *testing.T) {
- // FIXME
- t.Skip()
t.Run("EnsureDefaultSetupAssistant", func(t *testing.T) {
ds := new(mock.Store)
ctx := context.Background()
@@ -70,6 +68,9 @@ func TestDEPService(t *testing.T) {
Token: p.Token,
Type: p.Type,
DEPProfile: p.DEPProfile,
+ UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{
+ UpdateTimestamp: fleet.UpdateTimestamp{UpdatedAt: time.Now()},
+ },
}
savedProfile = res
return res, nil
@@ -122,7 +123,7 @@ func TestDEPService(t *testing.T) {
return 0, nil
}
- profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil, "")
+ profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil, "org1")
require.NoError(t, err)
require.Equal(t, "abcd", profUUID)
require.NotZero(t, modTime)
diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go
index 68bd90c82e14..6a098a5284ef 100644
--- a/server/mdm/apple/commander.go
+++ b/server/mdm/apple/commander.go
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"fmt"
"net/http"
+ "sort"
+ "strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -383,14 +385,15 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [
// Even if we didn't get an error, some of the APNs
// responses might have failed, signal that to the caller.
- var failed []string
+ failed := map[string]error{}
for uuid, response := range apnsResponses {
if response.Err != nil {
- failed = append(failed, uuid)
+ failed[uuid] = response.Err
}
}
+
if len(failed) > 0 {
- return &APNSDeliveryError{FailedUUIDs: failed, Err: err}
+ return &APNSDeliveryError{errorsByUUID: failed}
}
return nil
@@ -399,14 +402,38 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [
// APNSDeliveryError records an error and the associated host UUIDs in which it
// occurred.
type APNSDeliveryError struct {
- FailedUUIDs []string
- Err error
+ errorsByUUID map[string]error
}
func (e *APNSDeliveryError) Error() string {
- return fmt.Sprintf("APNS delivery failed with: %s, for UUIDs: %v", e.Err, e.FailedUUIDs)
+ var uuids []string
+ for uuid := range e.errorsByUUID {
+ uuids = append(uuids, uuid)
+ }
+
+ // sort UUIDs alphabetically for deterministic output
+ sort.Strings(uuids)
+
+ var errStrings []string
+ for _, uuid := range uuids {
+ errStrings = append(errStrings, fmt.Sprintf("UUID: %s, Error: %v", uuid, e.errorsByUUID[uuid]))
+ }
+
+ return fmt.Sprintf(
+ "APNS delivery failed with the following errors:\n%s",
+ strings.Join(errStrings, "\n"),
+ )
}
-func (e *APNSDeliveryError) Unwrap() error { return e.Err }
+func (e *APNSDeliveryError) FailedUUIDs() []string {
+ var uuids []string
+ for uuid := range e.errorsByUUID {
+ uuids = append(uuids, uuid)
+ }
+
+ // sort UUIDs alphabetically for deterministic output
+ sort.Strings(uuids)
+ return uuids
+}
func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway }
diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go
index 5978944b52d8..0d21c66ab5fb 100644
--- a/server/mdm/apple/commander_test.go
+++ b/server/mdm/apple/commander_test.go
@@ -3,7 +3,9 @@ package apple_mdm
import (
"context"
"crypto/tls"
+ "errors"
"fmt"
+ "net/http"
"os"
"testing"
@@ -200,3 +202,50 @@ func mobileconfigForTest(name, identifier string) []byte {
`, name, identifier, uuid.New().String()))
}
+
+func TestAPNSDeliveryError(t *testing.T) {
+ tests := []struct {
+ name string
+ errorsByUUID map[string]error
+ expectedError string
+ expectedFailedUUIDs []string
+ expectedStatusCode int
+ }{
+ {
+ name: "single error",
+ errorsByUUID: map[string]error{
+ "uuid1": errors.New("network error"),
+ },
+ expectedError: `APNS delivery failed with the following errors:
+UUID: uuid1, Error: network error`,
+ expectedFailedUUIDs: []string{"uuid1"},
+ expectedStatusCode: http.StatusBadGateway,
+ },
+ {
+ name: "multiple errors, sorted",
+ errorsByUUID: map[string]error{
+ "uuid3": errors.New("timeout error"),
+ "uuid1": errors.New("network error"),
+ "uuid2": errors.New("certificate error"),
+ },
+ expectedError: `APNS delivery failed with the following errors:
+UUID: uuid1, Error: network error
+UUID: uuid2, Error: certificate error
+UUID: uuid3, Error: timeout error`,
+ expectedFailedUUIDs: []string{"uuid1", "uuid2", "uuid3"},
+ expectedStatusCode: http.StatusBadGateway,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ apnsErr := &APNSDeliveryError{
+ errorsByUUID: tt.errorsByUUID,
+ }
+
+ require.Equal(t, tt.expectedError, apnsErr.Error())
+ require.Equal(t, tt.expectedFailedUUIDs, apnsErr.FailedUUIDs())
+ require.Equal(t, tt.expectedStatusCode, apnsErr.StatusCode())
+ })
+ }
+}
diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go
index a2dfdf44380b..b71d2db5ab98 100644
--- a/server/mdm/apple/mobileconfig/profiles.go
+++ b/server/mdm/apple/mobileconfig/profiles.go
@@ -1,6 +1,11 @@
package mobileconfig
-import "text/template"
+import (
+ "encoding/xml"
+ "fmt"
+ "strings"
+ "text/template"
+)
var funcMap = map[string]any{
"xml": XMLEscapeString,
@@ -113,3 +118,40 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro
`))
+
+var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.FuncMap{"xml": func(v string) (string, error) {
+ var escaped strings.Builder
+ if err := xml.EscapeText(&escaped, []byte(v)); err != nil {
+ return "", fmt.Errorf("XML escaping in OTA profile: %w", err)
+ }
+ return escaped.String(), nil
+}}).Option("missingkey=error").Parse(`
+
+
+
+ PayloadContent
+
+ URL
+ {{ .URL }}
+ DeviceAttributes
+
+ UDID
+ VERSION
+ PRODUCT
+ SERIAL
+
+
+ PayloadOrganization
+ {{ xml .Organization }}
+ PayloadDisplayName
+ {{ xml .Organization }} enrollment
+ PayloadVersion
+ 1
+ PayloadUUID
+ fdb376e5-b5bb-4d8c-829e-e90865f990c9
+ PayloadIdentifier
+ com.fleetdm.fleet.mdm.apple.ota
+ PayloadType
+ Profile Service
+
+ `))
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 128371de5079..999cf2aa077c 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -396,7 +396,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, selfService bool) (string, error)
+type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error)
type ListSoftwareForVulnDetectionFunc func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error)
@@ -494,6 +494,8 @@ type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[s
type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error)
+type GetPoliciesWithAssociatedInstallerFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error)
+
type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error)
type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error
@@ -778,7 +780,7 @@ type ListMDMAppleProfilesToRemoveFunc func(ctx context.Context) ([]*fleet.MDMApp
type BulkUpsertMDMAppleHostProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error
-type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error
+type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error)
type GetMDMAppleProfilesContentsFunc func(ctx context.Context, profileUUIDs []string) (map[string]mobileconfig.Mobileconfig, error)
@@ -842,7 +844,7 @@ type DeleteHostDEPAssignmentsFunc func(ctx context.Context, serials []string) er
type UpdateHostDEPAssignProfileResponsesFunc func(ctx context.Context, resp *godep.ProfileResponse) error
-type ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error)
+type ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error)
type GetDEPAssignProfileExpiredCooldownsFunc func(ctx context.Context) (map[uint][]string, error)
@@ -970,7 +972,7 @@ type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindow
type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error
-type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error
+type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error)
type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error)
@@ -1024,6 +1026,8 @@ type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string)
type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error)
+type GetHostLastInstallDataFunc func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error)
+
type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error)
type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error)
@@ -1776,6 +1780,9 @@ type DataStore struct {
GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFunc
GetTeamHostsPolicyMembershipsFuncInvoked bool
+ GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFunc
+ GetPoliciesWithAssociatedInstallerFuncInvoked bool
+
GetCalendarPoliciesFunc GetCalendarPoliciesFunc
GetCalendarPoliciesFuncInvoked bool
@@ -2571,6 +2578,9 @@ type DataStore struct {
ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFunc
ListPendingSoftwareInstallsFuncInvoked bool
+ GetHostLastInstallDataFunc GetHostLastInstallDataFunc
+ GetHostLastInstallDataFuncInvoked bool
+
MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFunc
MatchOrCreateSoftwareInstallerFuncInvoked bool
@@ -3950,11 +3960,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, selfService bool) (string, error) {
+func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) {
s.mu.Lock()
s.InsertSoftwareInstallRequestFuncInvoked = true
s.mu.Unlock()
- return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService)
+ return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareInstallerID, selfService)
}
func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) {
@@ -4293,6 +4303,13 @@ func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain st
return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID)
}
+func (s *DataStore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) {
+ s.mu.Lock()
+ s.GetPoliciesWithAssociatedInstallerFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetPoliciesWithAssociatedInstallerFunc(ctx, teamID, policyIDs)
+}
+
func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
s.mu.Lock()
s.GetCalendarPoliciesFuncInvoked = true
@@ -5287,7 +5304,7 @@ func (s *DataStore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload
return s.BulkUpsertMDMAppleHostProfilesFunc(ctx, payload)
}
-func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error {
+func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) {
s.mu.Lock()
s.BulkSetPendingMDMHostProfilesFuncInvoked = true
s.mu.Unlock()
@@ -5511,7 +5528,7 @@ func (s *DataStore) UpdateHostDEPAssignProfileResponses(ctx context.Context, res
return s.UpdateHostDEPAssignProfileResponsesFunc(ctx, resp)
}
-func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) {
+func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) {
s.mu.Lock()
s.ScreenDEPAssignProfileSerialsForCooldownFuncInvoked = true
s.mu.Unlock()
@@ -5959,7 +5976,7 @@ func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp f
return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp)
}
-func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error {
+func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) {
s.mu.Lock()
s.BatchSetMDMProfilesFuncInvoked = true
s.mu.Unlock()
@@ -6148,6 +6165,13 @@ func (s *DataStore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint
return s.ListPendingSoftwareInstallsFunc(ctx, hostID)
}
+func (s *DataStore) GetHostLastInstallData(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) {
+ s.mu.Lock()
+ s.GetHostLastInstallDataFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetHostLastInstallDataFunc(ctx, hostID, installerID)
+}
+
func (s *DataStore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
s.mu.Lock()
s.MatchOrCreateSoftwareInstallerFuncInvoked = true
diff --git a/server/service/activities.go b/server/service/activities.go
index cdab1837f1a2..861c87309520 100644
--- a/server/service/activities.go
+++ b/server/service/activities.go
@@ -85,7 +85,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
var userName *string
var userEmail *string
if user != nil {
- userID = &user.ID
+ // To support creating activities with users that were deleted. This can happen
+ // for automatically installed software which uses the author of the upload as the author of
+ // the installation.
+ if user.ID != 0 {
+ userID = &user.ID
+ }
userName = &user.Name
userEmail = &user.Email
}
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index 956de2b96b6b..236564eff0c1 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -414,12 +414,12 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
return nil, ctxerr.Wrap(ctx, err, "validating MDM config")
}
- abmAssignments, err := svc.validateABMAssignments(ctx, &appConfig.MDM, &oldAppConfig.MDM, invalid, license)
+ abmAssignments, err := svc.validateABMAssignments(ctx, &newAppConfig.MDM, &oldAppConfig.MDM, invalid, license)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating ABM token assignments")
}
- vppAssignments, err := svc.validateVPPAssignments(ctx, &appConfig.MDM, invalid, license)
+ vppAssignments, err := svc.validateVPPAssignments(ctx, &newAppConfig.MDM, invalid, license)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating VPP token assignments")
}
@@ -545,15 +545,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
}
}
- if appConfig.MDM.AppleBussinessManager.Set || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" {
+ if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" {
for _, tok := range abmAssignments {
+ fmt.Println(tok.EncryptedToken)
if err := svc.ds.SaveABMToken(ctx, tok); err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments")
}
}
}
- if appConfig.MDM.VolumePurchasingProgram.Set {
+ if appConfig.MDM.VolumePurchasingProgram.Set && appConfig.MDM.VolumePurchasingProgram.Valid {
for tokenID, tokenTeams := range vppAssignments {
if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments")
@@ -970,7 +971,7 @@ func (svc *Service) validateABMAssignments(
invalid *fleet.InvalidArgumentError,
license *fleet.LicenseInfo,
) ([]*fleet.ABMToken, error) {
- if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBussinessManager.Set && mdm.AppleBussinessManager.Valid {
+ if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid {
invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage)
return nil, nil
}
@@ -1008,7 +1009,7 @@ func (svc *Service) validateABMAssignments(
return []*fleet.ABMToken{tok}, nil
}
- if mdm.AppleBussinessManager.Set && mdm.AppleBussinessManager.Valid {
+ if mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid {
if !license.IsPremium() {
invalid.Append("mdm.apple_business_manager", ErrMissingLicense.Error())
return nil, nil
@@ -1040,7 +1041,7 @@ func (svc *Service) validateABMAssignments(
}
var tokensToSave []*fleet.ABMToken
- for _, bm := range mdm.AppleBussinessManager.Value {
+ for _, bm := range mdm.AppleBusinessManager.Value {
for _, tmName := range []string{bm.MacOSTeam, bm.IOSTeam, bm.IpadOSTeam} {
if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok {
invalid.Appendf("mdm.apple_business_manager", "team %s doesn't exist", tmName)
@@ -1101,10 +1102,10 @@ func (svc *Service) validateVPPAssignments(
token.Teams = nil
}
- var tokensToSave map[uint][]uint
+ tokensToSave := make(map[uint][]uint, len(mdm.VolumePurchasingProgram.Value))
for _, vpp := range mdm.VolumePurchasingProgram.Value {
for _, tmName := range vpp.Teams {
- if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok {
+ if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok && tmName != fleet.TeamNameAllTeams {
invalid.Appendf("mdm.volume_purchasing_program", "team %s doesn't exist", tmName)
return nil, nil
}
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index 67173e194244..0fb0d318d26f 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -827,8 +827,6 @@ func TestTransparencyURLDowngradeLicense(t *testing.T) {
}
func TestMDMAppleConfig(t *testing.T) {
- // FIXME
- t.Skip()
ds := new(mock.Store)
depStorage := new(nanodep_mock.Storage)
@@ -860,11 +858,13 @@ func TestMDMAppleConfig(t *testing.T) {
name: "nochange",
licenseTier: "free",
expectedMDM: fleet.MDM{
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
- MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
+ MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
+ WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
@@ -891,11 +891,13 @@ func TestMDMAppleConfig(t *testing.T) {
findTeam: true,
newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"},
expectedMDM: fleet.MDM{
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
DeprecatedAppleBMDefaultTeam: "foobar",
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -908,11 +910,13 @@ func TestMDMAppleConfig(t *testing.T) {
oldMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "bar"},
newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"},
expectedMDM: fleet.MDM{
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
DeprecatedAppleBMDefaultTeam: "foobar",
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -931,12 +935,14 @@ func TestMDMAppleConfig(t *testing.T) {
newMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}},
oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}},
expectedMDM: fleet.MDM{
- EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
- MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
+ EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
+ MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
+ WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
@@ -952,17 +958,19 @@ func TestMDMAppleConfig(t *testing.T) {
IDPName: "onelogin",
}}},
expectedMDM: fleet.MDM{
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IssuerURI: "http://issuer.idp.com",
MetadataURL: "http://isser.metadata.com",
IDPName: "onelogin",
}},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
- MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
+ MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
+ WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
@@ -1017,12 +1025,14 @@ func TestMDMAppleConfig(t *testing.T) {
EnableDiskEncryption: optjson.SetBool(false),
},
expectedMDM: fleet.MDM{
- EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
- MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
- WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
+ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}},
+ EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
+ MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
+ VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}},
+ WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
@@ -1065,6 +1075,12 @@ func TestMDMAppleConfig(t *testing.T) {
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return job, nil
}
+ ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
+ return []*fleet.ABMToken{{ID: 1}}, nil
+ }
+ ds.SaveABMTokenFunc = func(ctx context.Context, token *fleet.ABMToken) error {
+ return nil
+ }
depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) {
return &nanodep_client.Config{BaseURL: depSrv.URL}, nil
}
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 4a02bd608e9f..3072cd710cd6 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -380,7 +380,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
}
return nil, ctxerr.Wrap(ctx, err)
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
@@ -470,7 +470,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r i
return nil, err
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil {
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host declarations")
}
@@ -773,7 +773,7 @@ func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID
return ctxerr.Wrap(ctx, err)
}
// cannot use the profile ID as it is now deleted
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
@@ -853,7 +853,7 @@ func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID stri
return ctxerr.Wrap(ctx, err)
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
@@ -1570,47 +1570,17 @@ func (svc *Service) EnqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co
return ctxerr.Wrap(ctx, err, "logging activity for mdm apple remove profile command")
}
- return svc.pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx, cmdUUID, h.UUID, info.Platform)
-}
-
-func (svc *Service) pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, cmdUUID string, deviceID string, platform string) error {
- ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(5*time.Second))
- ticker := time.NewTicker(300 * time.Millisecond)
- defer func() {
- ticker.Stop()
- cancelFn()
- }()
-
- for {
- select {
- case <-ctx.Done():
- // time out after 5 seconds
- return fleet.MDMAppleCommandTimeoutError{}
- case <-ticker.C:
- nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, deviceID)
- if err != nil {
- level.Error(svc.logger).Log("err", "get nanomdm enrollment status", "details", err, "id", deviceID, "command_uuid", cmdUUID)
- return err
- }
- if nanoEnroll != nil && nanoEnroll.Enabled {
- // check again on the next tick
- continue
- }
- // success, mdm enrollment is no longer enabled for the device
- level.Info(svc.logger).Log("msg", "mdm disabled for device", "id", deviceID, "command_uuid", cmdUUID)
-
- mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
- err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
- Action: mdmlifecycle.HostActionTurnOff,
- Platform: platform,
- UUID: deviceID,
- })
- if err != nil {
- return err
- }
- return nil
- }
+ mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
+ err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
+ Action: mdmlifecycle.HostActionTurnOff,
+ Platform: info.Platform,
+ UUID: h.UUID,
+ })
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "running turn off action in mdm lifecycle")
}
+
+ return nil
}
type mdmAppleGetInstallerRequest struct {
@@ -1978,7 +1948,7 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
}
if !skipBulkPending {
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
}
@@ -3149,7 +3119,7 @@ func SendPushesToPendingDevices(
if err := commander.SendNotifications(ctx, uuids); err != nil {
var apnsErr *apple_mdm.APNSDeliveryError
if errors.As(err, &apnsErr) {
- level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "host_uuids", apnsErr.FailedUUIDs)
+ level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "error", apnsErr.Error())
return nil
}
@@ -4177,3 +4147,45 @@ func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID
return nil, fleet.ErrMissingLicense
}
+
+////////////////////////////////////////////////////////////////////////////////
+// GET /enrollment_profiles/ota
+////////////////////////////////////////////////////////////////////////////////
+
+type getOTAProfileRequest struct {
+ EnrollSecret string `query:"enroll_secret"`
+}
+
+func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*getOTAProfileRequest)
+ profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret)
+ if err != nil {
+ return &getMDMAppleConfigProfileResponse{Err: err}, err
+ }
+
+ reader := bytes.NewReader(profile)
+ return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil
+}
+
+func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) {
+ // Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done
+ // by the enroll secret verification below
+ svc.authz.SkipAuthorization(ctx)
+
+ cfg, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name")
+ }
+
+ profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file")
+ }
+
+ signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "signing profile")
+ }
+
+ return signed, nil
+}
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index 718bfd183cee..a8b20b04ec25 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -599,8 +599,9 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) {
ds.GetMDMAppleProfilesSummaryFunc = func(context.Context, *uint) (*fleet.MDMProfilesSummary, error) {
return nil, nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
mockGetFuncWithTeamID := func(teamID uint) mock.GetMDMAppleConfigProfileFunc {
return func(ctx context.Context, puid string) (*fleet.MDMAppleConfigProfile, error) {
@@ -706,8 +707,9 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, false)
@@ -1499,8 +1501,9 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) {
) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) {
return nil, nil, nil
@@ -1815,8 +1818,9 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) {
) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) {
return nil, nil, nil
diff --git a/server/service/client.go b/server/service/client.go
index b811277e38f8..3ae6def1b3b6 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -1285,9 +1285,13 @@ func (c *Client) DoGitOps(
team["webhook_settings"] = map[string]interface{}{}
clearHostStatusWebhook := true
if webhookSettings, ok := config.TeamSettings["webhook_settings"]; ok {
- if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok {
- clearHostStatusWebhook = false
- team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook
+ if _, ok := webhookSettings.(map[string]interface{}); ok {
+ if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok {
+ clearHostStatusWebhook = false
+ team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook
+ }
+ } else if webhookSettings != nil {
+ return nil, fmt.Errorf("team_settings.webhook_settings config is not a map but a %T", webhookSettings)
}
}
if clearHostStatusWebhook {
diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go
index acb3d23c7163..055eb2cba1a8 100644
--- a/server/service/endpoint_utils.go
+++ b/server/service/endpoint_utils.go
@@ -448,9 +448,12 @@ var pathReplacer = strings.NewReplacer(
"}", "_",
)
-func getNameFromPathAndVerb(verb, path string) string {
- return strings.ToLower(verb) + "_" +
- pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/"))
+func getNameFromPathAndVerb(verb, path, startAt string) string {
+ prefix := strings.ToLower(verb) + "_"
+ if startAt != "" {
+ prefix += pathReplacer.Replace(startAt) + "_"
+ }
+ return prefix + pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/"))
}
func capabilitiesResponseFunc(capabilities fleet.CapabilityMap) kithttp.ServerOption {
@@ -560,14 +563,14 @@ func (e *authEndpointer) handlePathHandler(path string, pathHandler func(path st
}
versionedPath := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1)
- nameAndVerb := getNameFromPathAndVerb(verb, path)
+ nameAndVerb := getNameFromPathAndVerb(verb, path, e.startingAtVersion)
if e.usePathPrefix {
e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
} else {
e.r.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
}
for _, alias := range e.alternativePaths {
- nameAndVerb := getNameFromPathAndVerb(verb, alias)
+ nameAndVerb := getNameFromPathAndVerb(verb, alias, e.startingAtVersion)
versionedPath := strings.Replace(alias, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1)
if e.usePathPrefix {
e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
diff --git a/server/service/frontend.go b/server/service/frontend.go
index f5d884ec1fe0..a2a6058b5459 100644
--- a/server/service/frontend.go
+++ b/server/service/frontend.go
@@ -1,9 +1,11 @@
package service
import (
+ "fmt"
"html/template"
"io"
"net/http"
+ "net/url"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/fleetdm/fleet/v4/server/bindata"
@@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl
})
}
+func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler {
+ herr := func(w http.ResponseWriter, err string) {
+ logger.Log("err", err)
+ http.Error(w, err, http.StatusInternalServerError)
+ }
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ writeBrowserSecurityHeaders(w)
+
+ fs := newBinaryFileSystem("/frontend")
+ file, err := fs.Open("templates/enroll-ota.html")
+ if err != nil {
+ herr(w, "load enroll ota template: "+err.Error())
+ return
+ }
+
+ data, err := io.ReadAll(file)
+ if err != nil {
+ herr(w, "read bindata file: "+err.Error())
+ return
+ }
+
+ t, err := template.New("enroll-ota").Parse(string(data))
+ if err != nil {
+ herr(w, "create react template: "+err.Error())
+ return
+ }
+
+ enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret"))
+ if err != nil {
+ herr(w, "generate enroll ota url: "+err.Error())
+ return
+ }
+ if err := t.Execute(w, struct {
+ EnrollURL string
+ URLPrefix string
+ }{
+ URLPrefix: urlPrefix,
+ EnrollURL: enrollURL,
+ }); err != nil {
+ herr(w, "execute react template: "+err.Error())
+ return
+ }
+ })
+}
+
+func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) {
+ path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota")
+ if err != nil {
+ return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err)
+ }
+
+ enrollURL, err := url.Parse(path)
+ if err != nil {
+ return "", fmt.Errorf("parsing end user ota enrollment url: %w", err)
+ }
+
+ q := enrollURL.Query()
+ q.Set("enroll_secret", enrollSecret)
+ enrollURL.RawQuery = q.Encode()
+ return enrollURL.String(), nil
+}
+
func ServeStaticAssets(path string) http.Handler {
return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets")))
}
diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go
index 32363d6dd833..2710b69e9db0 100644
--- a/server/service/frontend_test.go
+++ b/server/service/frontend_test.go
@@ -2,6 +2,7 @@ package service
import (
"bytes"
+ "io"
"net/http"
"net/http/httptest"
"os"
@@ -40,3 +41,29 @@ func TestServeFrontend(t *testing.T) {
require.NoError(t, err)
require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode)
}
+
+func TestServeEndUserEnrollOTA(t *testing.T) {
+ if !hasBuildTag("full") {
+ t.Skip("This test requires running with -tags full")
+ }
+ logger := log.NewLogfmtLogger(os.Stdout)
+ h := ServeEndUserEnrollOTA("", logger)
+ ts := httptest.NewServer(h)
+ t.Cleanup(func() {
+ ts.Close()
+ })
+
+ // assert html is returned
+ response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo")
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, response.StatusCode)
+ require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8")
+
+ // assert it contains the content we expect
+ defer response.Body.Close()
+ bodyBytes, err := io.ReadAll(response.Body)
+ require.NoError(t, err)
+ bodyString := string(bodyBytes)
+ require.Contains(t, bodyString, "Enroll your device to Fleet")
+ require.Contains(t, bodyString, "?enroll_secret=foo")
+}
diff --git a/server/service/global_policies.go b/server/service/global_policies.go
index c7d03e969547..ed0ef22013f4 100644
--- a/server/service/global_policies.go
+++ b/server/service/global_policies.go
@@ -6,12 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/fleetdm/fleet/v4/pkg/fleethttp"
"io"
"net/http"
"strings"
"time"
+ "github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
@@ -155,6 +155,9 @@ func (svc Service) GetPolicyByIDQueries(ctx context.Context, policyID uint) (*fl
if err != nil {
return nil, err
}
+ if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "populate install_software")
+ }
return policy, nil
}
@@ -583,8 +586,10 @@ func autofillPoliciesEndpoint(ctx context.Context, request interface{}, svc flee
}
// Exposing external URL and timeout for testing purposes
-var getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql"
-var getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second
+var (
+ getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql"
+ getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second
+)
type AutofillError struct {
Message string
diff --git a/server/service/handler.go b/server/service/handler.go
index c1cde37255e5..23b30a346195 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -887,6 +887,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token
neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{})
+ // Get OTA profile
+ neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{})
+
// These endpoint are used by Microsoft devices during MDM device enrollment phase
neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM())
diff --git a/server/service/handler_test.go b/server/service/handler_test.go
index df7ff9e04d4b..116b155ac99a 100644
--- a/server/service/handler_test.go
+++ b/server/service/handler_test.go
@@ -76,7 +76,6 @@ func TestAPIRoutesConflicts(t *testing.T) {
}
func TestAPIRoutesMetrics(t *testing.T) {
- t.Skip()
ds := new(mock.Store)
svc, _ := newTestService(t, ds, nil, nil)
@@ -108,7 +107,8 @@ func TestAPIRoutesMetrics(t *testing.T) {
routeNames := make(map[string]bool)
err = router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error {
if _, ok := routeNames[route.GetName()]; ok {
- t.Errorf("duplicate route name: %s", route.GetName())
+ path, _ := route.GetPathTemplate()
+ t.Errorf("duplicate route name: %s (%s)", route.GetName(), path)
}
routeNames[route.GetName()] = true
return nil
@@ -194,7 +194,7 @@ func TestAPIRoutesMetrics(t *testing.T) {
"go_memstats_alloc_bytes_total": 1,
"go_memstats_buck_hash_sys_bytes": 1,
"go_memstats_frees_total": 1,
- "go_memstats_gc_cpu_fraction": 1,
+ "go_memstats_gc_cpu_fraction": 0, // does not appear to be reported anymore
"go_memstats_gc_sys_bytes": 1,
"go_memstats_heap_alloc_bytes": 1,
"go_memstats_heap_idle_bytes": 1,
diff --git a/server/service/hosts.go b/server/service/hosts.go
index 359799b050dd..a4db5b73dc0a 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -826,7 +826,7 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []
return err
}
if !skipBulkPending {
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
}
@@ -962,7 +962,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, fi
if err := svc.ds.AddHostsToTeam(ctx, teamID, hostIDs); err != nil {
return err
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs)
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index 1e9e663ed6c5..d44f9fcf4d31 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -610,8 +610,9 @@ func TestHostAuth(t *testing.T) {
}
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
@@ -889,8 +890,9 @@ func TestAddHostsToTeamByFilter(t *testing.T) {
assert.Equal(t, expectedHostIDs, hostIDs)
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
@@ -931,8 +933,9 @@ func TestAddHostsToTeamByFilterLabel(t *testing.T) {
assert.Equal(t, expectedHostIDs, hostIDs)
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) {
return nil, nil
@@ -963,8 +966,9 @@ func TestAddHostsToTeamByFilterEmptyHosts(t *testing.T) {
ds.AddHostsToTeamFunc = func(ctx context.Context, teamID *uint, hostIDs []uint) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
emptyFilter := &map[string]interface{}{}
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index ef40dd7fb6ad..8b1f84820506 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -11595,6 +11595,9 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
t := s.T()
ctx := context.Background()
+ adminUser, err := s.ds.UserByEmail(ctx, "admin1@example.com")
+ require.NoError(t, err)
+
// there is already a datastore-layer test that verifies that correct values
// are returned for users, saved scripts, etc. so this is more focused on
// verifying that the service layer passes the proper options and the
@@ -11639,6 +11642,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
Title: "foo",
Source: "apps",
Version: "0.0.1",
+ UserID: adminUser.ID,
})
require.NoError(t, err)
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index f0878114dff8..838a06936f5b 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -11138,7 +11138,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
- // create a software installer and some host install requests
+ // Create software installers and corresponding host install requests.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
@@ -11148,6 +11148,24 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
+ payload2 := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install script 2",
+ PreInstallQuery: "pre install query 2",
+ PostInstallScript: "post install script 2",
+ Filename: "vim.deb",
+ Title: "vim",
+ }
+ s.uploadSoftwareInstaller(payload2, http.StatusOK, "")
+ titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages")
+ payload3 := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install script 3",
+ PreInstallQuery: "pre install query 3",
+ PostInstallScript: "post install script 3",
+ Filename: "emacs.deb",
+ Title: "emacs",
+ }
+ s.uploadSoftwareInstaller(payload3, http.StatusOK, "")
+ titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages")
latestInstallUUID := func() string {
var id string
@@ -11159,9 +11177,10 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
// create some install requests for the host
installUUIDs := make([]string, 3)
+ titleIDs := []uint{titleID, titleID2, titleID3}
for i := 0; i < len(installUUIDs); i++ {
resp := installSoftwareResponse{}
- s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp)
+ s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp)
installUUIDs[i] = latestInstallUUID()
}
@@ -11224,7 +11243,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
Status: fleet.SoftwareInstallerFailed,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy),
})
- wantAct.InstallUUID = installUUIDs[1]
+ wantAct = fleet.ActivityTypeInstalledSoftware{
+ HostID: host.ID,
+ HostDisplayName: host.DisplayName(),
+ SoftwareTitle: payload2.Title,
+ SoftwarePackage: payload2.Filename,
+ InstallUUID: installUUIDs[1],
+ Status: string(fleet.SoftwareInstallerFailed),
+ }
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
s.Do("POST", "/api/fleet/orbit/software_install/result",
@@ -11246,8 +11272,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")),
PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")),
})
- wantAct.InstallUUID = installUUIDs[2]
- wantAct.Status = string(fleet.SoftwareInstallerInstalled)
+ wantAct = fleet.ActivityTypeInstalledSoftware{
+ HostID: host.ID,
+ HostDisplayName: host.DisplayName(),
+ SoftwareTitle: payload3.Title,
+ SoftwarePackage: payload3.Filename,
+ InstallUUID: installUUIDs[2],
+ Status: string(fleet.SoftwareInstallerInstalled),
+ }
lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
// non-existing installation uuid
@@ -11365,7 +11397,11 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
require.EqualValues(t, 0, *scriptRes.ExitCode)
}
-func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) {
+func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(
+ payload *fleet.UploadSoftwareInstallerPayload,
+ expectedStatus int,
+ expectedError string,
+) {
t := s.T()
t.Helper()
openFile := func(name string) *os.File {
@@ -11410,6 +11446,8 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.
}
r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers)
+ defer r.Body.Close()
+
if expectedError != "" {
errMsg := extractServerErrorText(r.Body)
require.Contains(t, errMsg, expectedError)
@@ -11733,6 +11771,21 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() {
)
}
+func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() {
+ t := s.T()
+ ctx := context.Background()
+
+ team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
+ require.NoError(t, err)
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "some installer script",
+ Filename: "no_version.pkg",
+ TeamID: &team.ID,
+ }
+ s.uploadSoftwareInstaller(payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.")
+}
+
// 1. host reports software
// 2. reconciler runs, creates title
// 3. installer is uploaded, matches existing software title
@@ -12722,3 +12775,619 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() {
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.")
}
+
+func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() {
+ t := s.T()
+ ctx := context.Background()
+
+ team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"})
+ require.NoError(t, err)
+ team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"})
+ require.NoError(t, err)
+
+ newHost := func(name string, teamID *uint, platform string) *fleet.Host {
+ h, err := s.ds.NewHost(ctx, &fleet.Host{
+ DetailUpdatedAt: time.Now(),
+ LabelUpdatedAt: time.Now(),
+ PolicyUpdatedAt: time.Now(),
+ SeenTime: time.Now().Add(-1 * time.Minute),
+ OsqueryHostID: ptr.String(t.Name() + name),
+ NodeKey: ptr.String(t.Name() + name),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
+ Platform: platform,
+ TeamID: teamID,
+ })
+ require.NoError(t, err)
+ return h
+ }
+ newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host {
+ h := newHost(name, teamID, platform)
+ orbitKey := setOrbitEnrollment(t, h, s.ds)
+ h.OrbitNodeKey = &orbitKey
+ return h
+ }
+
+ host0NoTeam := newFleetdHost("host1NoTeam", nil, "darwin")
+ host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin")
+ host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu")
+ host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows")
+ hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin")
+
+ // Upload dummy_installer.pkg to team1.
+ pkgPayload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "some pkg install script",
+ Filename: "dummy_installer.pkg",
+ TeamID: &team1.ID,
+ }
+ s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "")
+ // Get software title ID of the uploaded installer.
+ resp := listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "query", "DummyApp.app",
+ "team_id", fmt.Sprintf("%d", team1.ID),
+ )
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ dummyInstallerPkgTitleID := resp.SoftwareTitles[0].ID
+ var dummyInstallerPkg struct {
+ ID uint `db:"id"`
+ UserID *uint `db:"user_id"`
+ UserName string `db:"user_name"`
+ UserEmail string `db:"user_email"`
+ }
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q,
+ &dummyInstallerPkg,
+ `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
+ team1.ID, "dummy_installer.pkg",
+ )
+ })
+ dummyInstallerPkgInstallerID := dummyInstallerPkg.ID
+ require.NotZero(t, dummyInstallerPkgInstallerID)
+ require.NotNil(t, dummyInstallerPkg.UserID)
+ globalAdmin, err := s.ds.UserByEmail(ctx, "admin1@example.com")
+ require.NoError(t, err)
+ require.Equal(t, globalAdmin.ID, *dummyInstallerPkg.UserID)
+ require.Equal(t, "Test Name admin1@example.com", dummyInstallerPkg.UserName)
+ require.Equal(t, "admin1@example.com", dummyInstallerPkg.UserEmail)
+
+ // Upload ruby.deb to team1 by a user who will be deleted.
+ u2 := &fleet.User{
+ Name: "admin team1",
+ Email: "admin_team1@example.com",
+ GlobalRole: nil,
+ Teams: []fleet.UserTeam{
+ {
+ Team: *team1,
+ Role: fleet.RoleAdmin,
+ },
+ },
+ }
+ require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10))
+ adminTeam1, err := s.ds.NewUser(context.Background(), u2)
+ require.NoError(t, err)
+ rubyPayload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "some deb install script",
+ Filename: "ruby.deb",
+ TeamID: &team1.ID,
+ }
+ sessionKey := uuid.New().String()
+ adminTeam1Session, err := s.ds.NewSession(ctx, adminTeam1.ID, sessionKey)
+ require.NoError(t, err)
+ adminToken := s.token
+ t.Cleanup(func() {
+ s.token = adminToken
+ })
+ s.token = adminTeam1Session.Key
+ s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "")
+ s.token = adminToken
+ err = s.ds.DeleteUser(ctx, adminTeam1.ID)
+ require.NoError(t, err)
+ // Get software title ID of the uploaded installer.
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "query", "ruby",
+ "team_id", fmt.Sprintf("%d", team1.ID),
+ )
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ rubyDebTitleID := resp.SoftwareTitles[0].ID
+ var rubyDeb struct {
+ ID uint `db:"id"`
+ UserID *uint `db:"user_id"`
+ UserName string `db:"user_name"`
+ UserEmail string `db:"user_email"`
+ }
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q,
+ &rubyDeb,
+ `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
+ team1.ID, "ruby.deb",
+ )
+ })
+ rubyDebInstallerID := rubyDeb.ID
+ require.NotZero(t, rubyDebInstallerID)
+ require.Nil(t, rubyDeb.UserID)
+ require.Equal(t, "admin team1", rubyDeb.UserName)
+ require.Equal(t, "admin_team1@example.com", rubyDeb.UserEmail)
+
+ // Upload fleet-osquery.msi to team2.
+ fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "some msi install script",
+ Filename: "fleet-osquery.msi",
+ TeamID: &team2.ID,
+ // Set as Self-service to check that the generated host_software_installs
+ // is generated with self_service=false and the activity has the correct
+ // author (the admin that uploaded the installer).
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "")
+ // Get software title ID of the uploaded installer.
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "query", "Fleet osquery",
+ "team_id", fmt.Sprintf("%d", team2.ID),
+ )
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ fleetOsqueryMSITitleID := resp.SoftwareTitles[0].ID
+ var fleetOsqueryMSIInstallerID uint
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q,
+ &fleetOsqueryMSIInstallerID,
+ `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`,
+ team2.ID, "fleet-osquery.msi",
+ )
+ })
+ require.NotZero(t, fleetOsqueryMSIInstallerID)
+
+ // Create a VPP app to test that policies cannot be assigned to them.
+ _, err = s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
+ Name: "App123 " + t.Name(),
+ BundleIdentifier: "bid_" + t.Name(),
+ VPPAppTeam: fleet.VPPAppTeam{
+ VPPAppID: fleet.VPPAppID{
+ AdamID: "adam_" + t.Name(),
+ Platform: fleet.MacOSPlatform,
+ },
+ },
+ }, &team1.ID)
+ require.NoError(t, err)
+ // Get software title ID of the uploaded VPP app.
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "query", "App123",
+ "team_id", fmt.Sprintf("%d", team1.ID),
+ )
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.NotNil(t, resp.SoftwareTitles[0].AppStoreApp)
+ vppAppTitleID := resp.SoftwareTitles[0].ID
+
+ // Populate software for host1Team1 (to have a software title
+ // that doesn't have an associated installer)
+ software := []fleet.Software{
+ {Name: "Foobar.app", Version: "0.0.1", Source: "apps"},
+ }
+ _, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software)
+ require.NoError(t, err)
+ require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now()))
+ require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx))
+ require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now()))
+ // Get software title ID of the software.
+ resp = listSoftwareTitlesResponse{}
+ s.DoJSON(
+ "GET", "/api/latest/fleet/software/titles",
+ listSoftwareTitlesRequest{},
+ http.StatusOK, &resp,
+ "query", "Foobar.app",
+ "team_id", fmt.Sprintf("%d", team1.ID),
+ )
+ require.Len(t, resp.SoftwareTitles, 1)
+ require.Nil(t, resp.SoftwareTitles[0].SoftwarePackage)
+ foobarAppTitleID := resp.SoftwareTitles[0].ID
+
+ // policy0AllTeams is a global policy that runs on all devices.
+ policy0AllTeams, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{
+ Name: "policy0AllTeams",
+ Query: "SELECT 1;",
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+ // policy1Team1 runs on macOS devices.
+ policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
+ Name: "policy1Team1",
+ Query: "SELECT 1;",
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+ // policy2Team1 runs on macOS and Linux devices.
+ policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
+ Name: "policy2Team1",
+ Query: "SELECT 2;",
+ Platform: "linux,darwin",
+ })
+ require.NoError(t, err)
+ // policy3Team1 runs on all devices in team1 (will have no associated installers).
+ policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
+ Name: "policy3Team1",
+ Query: "SELECT 3;",
+ })
+ require.NoError(t, err)
+ // policy4Team2 runs on Windows devices.
+ policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
+ Name: "policy4Team2",
+ Query: "SELECT 4;",
+ Platform: "windows",
+ })
+ require.NoError(t, err)
+
+ // Attempt to associate to an unknown software title.
+ mtplr := modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: ptr.Uint(999_999),
+ },
+ }, http.StatusBadRequest, &mtplr)
+ // Attempt to associate to a software title without associated installer.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: ptr.Uint(foobarAppTitleID),
+ },
+ }, http.StatusBadRequest, &mtplr)
+ // Attempt to associate vppApp to policy1Team1 which should fail because we only allow associating software installers.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: &vppAppTitleID,
+ },
+ }, http.StatusBadRequest, &mtplr)
+ // Associate dummy_installer.pkg to policy1Team1.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: &dummyInstallerPkgTitleID,
+ },
+ }, http.StatusOK, &mtplr)
+ // Change name only (to test not setting a software_title_id).
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID),
+ json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr,
+ )
+ policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, policy1Team1.SoftwareInstallerID)
+ require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID)
+ require.Equal(t, "policy1Team1_Renamed", *&policy1Team1.Name)
+ // Explicit set to 0 to disable.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: ptr.Uint(0),
+ },
+ }, http.StatusOK, &mtplr)
+ policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
+ require.NoError(t, err)
+ require.Nil(t, policy1Team1.SoftwareInstallerID)
+ // Back to associating dummy_installer.pkg to policy1Team1.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: &dummyInstallerPkgTitleID,
+ },
+ }, http.StatusOK, &mtplr)
+ policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, policy1Team1.SoftwareInstallerID)
+ require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID)
+
+ // Associate ruby.deb to policy2Team1.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: &rubyDebTitleID,
+ },
+ }, http.StatusOK, &mtplr)
+
+ host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
+ require.NoError(t, err)
+ require.Nil(t, host1LastInstall)
+
+ // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the
+ // current user's "Authorization: Bearer " header.
+
+ // host1Team1 fails all policies on the first report.
+ // Failing policy1Team1 means an install request must be generated.
+ // Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts).
+ // Failing policy3Team1 should do nothing because it doesn't have any installers associated to it.
+ distributedResp := submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ policy1Team1.ID: ptr.Bool(false),
+ policy2Team1.ID: ptr.Bool(false),
+ policy3Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.NotEmpty(t, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+ prevExecutionID := host1LastInstall.ExecutionID
+
+ // Request a manual installation on the host for the same installer, which should fail.
+ var installResp installSoftwareResponse
+ s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d",
+ host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp)
+
+ // Submit same results as before, which should not trigger a installation because the policy is already failing.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ policy1Team1.ID: ptr.Bool(false),
+ policy2Team1.ID: ptr.Bool(false),
+ policy3Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+
+ // Submit same results but policy1Team1 now passes,
+ // and then submit again but policy1Team1 fails.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ policy1Team1.ID: ptr.Bool(true),
+ policy2Team1.ID: ptr.Bool(false),
+ policy3Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ policy1Team1.ID: ptr.Bool(false),
+ policy2Team1.ID: ptr.Bool(false),
+ policy3Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // Another installation should not be triggered because the last installation is pending.
+ host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID)
+ require.NoError(t, err)
+ require.NotNil(t, host1LastInstall)
+ require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
+ require.NotNil(t, host1LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
+
+ // host2Team1 is failing policy2Team1 and policy3Team1 policies.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host2Team1,
+ map[uint]*bool{
+ policy2Team1.ID: ptr.Bool(false),
+ policy3Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ host2LastInstall, err := s.ds.GetHostLastInstallData(ctx, host2Team1.ID, rubyDebInstallerID)
+ require.NoError(t, err)
+ require.NotNil(t, host2LastInstall)
+ require.NotEmpty(t, host2LastInstall.ExecutionID)
+ require.NotNil(t, host2LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host2LastInstall.Status)
+
+ // Associate fleet-osquery.msi to policy4Team2.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: &fleetOsqueryMSITitleID,
+ },
+ }, http.StatusOK, &mtplr)
+
+ // host3Team2 reports a failing result for policy4Team2, which should trigger an installation.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host3Team2,
+ map[uint]*bool{
+ policy4Team2.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ host3LastInstall, err := s.ds.GetHostLastInstallData(ctx, host3Team2.ID, fleetOsqueryMSIInstallerID)
+ require.NoError(t, err)
+ require.NotNil(t, host3LastInstall)
+ require.NotEmpty(t, host3LastInstall.ExecutionID)
+ require.NotNil(t, host3LastInstall.Status)
+ require.Equal(t, fleet.SoftwareInstallerPending, *host3LastInstall.Status)
+ host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID)
+ require.NoError(t, err)
+ // Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so
+ // host3LastInstallDetails.SelfService should be false.
+ require.False(t, host3LastInstallDetails.SelfService)
+
+ //
+ // The following increase coverage of policies result processing in distributed/write.
+ //
+
+ // host3Team2 reports a passing result for policy0AllTeams which is a global policy.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host3Team2,
+ map[uint]*bool{
+ policy0AllTeams.ID: ptr.Bool(true),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // host0NoTeam reports a failing result for policy0AllTeams which is a global policy.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host0NoTeam,
+ map[uint]*bool{
+ policy0AllTeams.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // host3Team2 reports a failing result for policy0AllTeams which is a global policy.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host3Team2,
+ map[uint]*bool{
+ policy0AllTeams.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // Unassociate policy4Team2 from installer.
+ mtplr = modifyTeamPolicyResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
+ ModifyPolicyPayload: fleet.ModifyPolicyPayload{
+ SoftwareTitleID: ptr.Uint(0),
+ },
+ }, http.StatusOK, &mtplr)
+
+ // host3Team2 reports a failing result for policy4Team2.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host3Team2,
+ map[uint]*bool{
+ policy4Team2.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // Upcoming activities for host1Team1 should show the automatic installation of dummy_installer.pkg.
+ // Check the author should be the admin that uploaded the installer.
+ var listUpcomingAct listHostUpcomingActivitiesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1Team1.ID), nil, http.StatusOK, &listUpcomingAct)
+ require.Len(t, listUpcomingAct.Activities, 1)
+ require.NotNil(t, listUpcomingAct.Activities[0].ActorID)
+ require.Equal(t, globalAdmin.ID, *listUpcomingAct.Activities[0].ActorID)
+ require.Equal(t, globalAdmin.Name, *listUpcomingAct.Activities[0].ActorFullName)
+ require.Equal(t, globalAdmin.Email, *listUpcomingAct.Activities[0].ActorEmail)
+
+ //
+ // Finally have orbit install the packages and check activities.
+ //
+
+ // host1Team1 posts the installation result for dummy_installer.pkg.
+ s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
+ "orbit_node_key": %q,
+ "install_uuid": %q,
+ "pre_install_condition_output": "ok",
+ "install_script_exit_code": 0,
+ "install_script_output": "ok"
+ }`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent)
+ s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
+ "host_id": %d,
+ "host_display_name": "%s",
+ "software_title": "%s",
+ "software_package": "%s",
+ "self_service": false,
+ "install_uuid": "%s",
+ "status": "installed"
+ }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp.app", "dummy_installer.pkg", host1LastInstall.ExecutionID), 0)
+
+ // host2Team1 posts the installation result for ruby.deb.
+ s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
+ "orbit_node_key": %q,
+ "install_uuid": %q,
+ "pre_install_condition_output": "ok",
+ "install_script_exit_code": 1,
+ "install_script_output": "failed"
+ }`, *host2Team1.OrbitNodeKey, host2LastInstall.ExecutionID)), http.StatusNoContent)
+ activityID := s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
+ "host_id": %d,
+ "host_display_name": "%s",
+ "software_title": "%s",
+ "software_package": "%s",
+ "self_service": false,
+ "install_uuid": "%s",
+ "status": "failed"
+ }`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID), 0)
+
+ // Check that the activity item generated for ruby.deb installation has a null user,
+ // but has name and email set.
+ var actor struct {
+ UserID *uint `db:"user_id"`
+ UserName *string `db:"user_name"`
+ UserEmail string `db:"user_email"`
+ }
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q,
+ &actor,
+ `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`,
+ activityID,
+ )
+ })
+ require.Nil(t, actor.UserID)
+ require.NotNil(t, actor.UserName)
+ require.Equal(t, "admin team1", *actor.UserName)
+ require.Equal(t, "admin_team1@example.com", actor.UserEmail)
+
+ // host3Team2 posts the installation result for fleet-osquery.msi.
+ s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
+ "orbit_node_key": %q,
+ "install_uuid": %q,
+ "pre_install_condition_output": "ok",
+ "install_script_exit_code": 1,
+ "install_script_output": "failed"
+ }`, *host3Team2.OrbitNodeKey, host3LastInstall.ExecutionID)), http.StatusNoContent)
+ activityID = s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{
+ "host_id": %d,
+ "host_display_name": "%s",
+ "software_title": "%s",
+ "software_package": "%s",
+ "self_service": false,
+ "install_uuid": "%s",
+ "status": "failed"
+ }`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID), 0)
+
+ // Check that the activity item generated for fleet-osquery.msi installation has the admin user set as author.
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q,
+ &actor,
+ `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`,
+ activityID,
+ )
+ })
+ require.NotNil(t, actor.UserID)
+ require.Equal(t, globalAdmin.ID, *actor.UserID)
+ require.NotNil(t, actor.UserName)
+ require.Equal(t, "Test Name admin1@example.com", *actor.UserName)
+ require.Equal(t, "admin1@example.com", actor.UserEmail)
+
+ // hostVanillaOsquery5Team1 sends policy results with failed policies with associated installers.
+ // Fleet should not queue an install for vanilla osquery hosts.
+ distributedResp = submitDistributedQueryResultsResponse{}
+ s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ hostVanillaOsquery5Team1,
+ map[uint]*bool{
+ policy1Team1.ID: ptr.Bool(false),
+ },
+ ), http.StatusOK, &distributedResp)
+ hostVanillaOsquery5Team1LastInstall, err := s.ds.GetHostLastInstallData(ctx, hostVanillaOsquery5Team1.ID, dummyInstallerPkgInstallerID)
+ require.NoError(t, err)
+ require.Nil(t, hostVanillaOsquery5Team1LastInstall)
+}
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
index b741e1412d52..fca30a229e75 100644
--- a/server/service/integration_mdm_dep_test.go
+++ b/server/service/integration_mdm_dep_test.go
@@ -38,8 +38,6 @@ type profileAssignmentReq struct {
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
@@ -96,6 +94,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
// enable FileVault
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage([]byte(`{"mdm":{"macos_settings":{"enable_disk_encryption":true}}}`)), http.StatusOK)
+ s.enableABM("fleet_ade_test")
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1")
@@ -105,8 +104,6 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
@@ -141,9 +138,15 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
// setup IdP so that AccountConfiguration profile is sent after DEP enrollment
var acResp appConfigResponse
+ s.enableABM("fleet_ade_test")
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
- "apple_bm_default_team": %q,
+ "apple_business_manager": [{
+ "organization_name": %q,
+ "macos_team": %q,
+ "ios_team": %q,
+ "ipados_team": %q
+ }],
"end_user_authentication": {
"entity_id": "https://localhost:8080",
"issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
@@ -154,7 +157,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
"enable_end_user_authentication": true
}
}
- }`, tm.Name)), http.StatusOK, &acResp)
+ }`, "fleet_ade_test", tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
require.NotEmpty(t, acResp.MDM.EndUserAuthentication)
// TODO(mna): how/where to pass an enroll_reference so that
@@ -195,7 +198,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
return map[string]*push.Response{}, nil
}
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse("fleet_ade_test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
@@ -382,8 +385,6 @@ func (s *integrationMDMTestSuite) expectAndScheduleReleaseDeviceJob(t *testing.T
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
devices := []godep.Device{
@@ -543,7 +544,9 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow
expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
+ s.enableABM(t.Name())
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
@@ -925,6 +928,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
+ s.runWorker()
// expect no assign profile request during cooldown
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
@@ -991,7 +995,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
- // run the inregration schedule and expect success
+ // run the integration schedule and expect success
expectAssignProfileResponseFailed = ""
profileAssignmentReqs = []profileAssignmentReq{}
s.runIntegrationsSchedule()
@@ -1130,10 +1134,8 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() {
t := s.T()
- // FIXME
- t.Skip()
- s.enableABM()
+ s.enableABM(t.Name())
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
@@ -1143,6 +1145,16 @@ func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() {
var acResp appConfigResponse
+ defer func() {
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "apple_bm_default_team": ""
+ }
+ }`), http.StatusOK, &acResp)
+ require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam)
+ }()
+
// try to set an invalid team name
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go
index 82f11ab679da..b487cd1db3ec 100644
--- a/server/service/integration_mdm_lifecycle_test.go
+++ b/server/service/integration_mdm_lifecycle_test.go
@@ -226,10 +226,9 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() {
})
t.Run("automatic enrollment", func(t *testing.T) {
- // FIXME
- t.Skip()
device := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, "")
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.enableABM(t.Name())
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
@@ -423,8 +422,6 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() {
})
t.Run("automatic enrollment", func(t *testing.T) {
- // FIXME
- t.Skip()
if strings.Contains(tt.Name, "wipe") {
t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO")
}
@@ -593,14 +590,9 @@ func (s *integrationMDMTestSuite) setupLifecycleSettings() {
// Host is renewing SCEP certificates
func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
// ensure there's a token for automatic enrollments
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
- }))
+ s.enableABM(t.Name())
s.runDEPSchedule()
// add a device that's manually enrolled
diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go
index 597f6e4f3dae..c60d0580e29d 100644
--- a/server/service/integration_mdm_profiles_test.go
+++ b/server/service/integration_mdm_profiles_test.go
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"sort"
"strconv"
"strings"
@@ -948,8 +949,9 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() {
ctx := context.Background()
t := s.T()
- // FIXME
- t.Skip()
+
+ // before we switch to a gitops token, ensure ABM is setup
+ s.enableABM(t.Name())
// Use a gitops user for all Puppet actions
u := &fleet.User{
@@ -984,7 +986,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() {
// create a setup assistant for no team, for this we need to:
// 1. mock the ABM API, as it gets called to set the profile
// 2. run the DEP schedule, as this registers the default profile
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
}))
@@ -3781,17 +3783,18 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
- s.lastActivityOfTypeMatches(
+ // Nothing changed, so no activity items
+ s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
- s.lastActivityOfTypeMatches(
+ s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
- s.lastActivityOfTypeMatches(
+ s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
@@ -4061,12 +4064,13 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent)
- s.lastActivityOfTypeMatches(
+ // Nothing changed, so no activity
+ s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
- s.lastActivityOfTypeMatches(
+ s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
@@ -4812,3 +4816,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
},
})
}
+
+func (s *integrationMDMTestSuite) TestOTAProfile() {
+ t := s.T()
+ ctx := context.Background()
+
+ // Getting profile for non-existent secret it's ok
+ s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real")
+
+ // Create an enroll secret; has some special characters that should be escaped in the profile
+ globalEnrollSec := "global_enroll+_/sec"
+ escSec := url.QueryEscape(globalEnrollSec)
+ s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
+ Spec: &fleet.EnrollSecretSpec{
+ Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
+ },
+ }, http.StatusOK)
+
+ cfg, err := s.ds.AppConfig(ctx)
+ require.NoError(t, err)
+
+ // Get profile with that enroll secret
+ resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec)
+ require.NotZero(t, resp.ContentLength)
+ require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
+ require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
+ b, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, resp.ContentLength, int64(len(b)))
+ require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
+ require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec))
+ require.Contains(t, string(b), cfg.OrgInfo.OrgName)
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index ef662a058939..c191896b5d8d 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -85,11 +85,9 @@ type integrationMDMTestSuite struct {
fleetDMNextCSRStatus atomic.Value
pushProvider *mock.APNSPushProvider
depStorage nanodep_storage.AllDEPStorage
- depSchedule *schedule.Schedule
profileSchedule *schedule.Schedule
integrationsSchedule *schedule.Schedule
onProfileJobDone func() // function called when profileSchedule.Trigger() job completed
- onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed
onIntegrationsScheduleDone func() // function called when integrationsSchedule.Trigger() job completed
mdmStorage *mysql.NanoMDMStorage
worker *worker.Worker
@@ -177,7 +175,6 @@ func (s *integrationMDMTestSuite) SetupSuite() {
return err
})
- var depSchedule *schedule.Schedule
var integrationsSchedule *schedule.Schedule
var profileSchedule *schedule.Schedule
cronLog := kitlog.NewJSONLogger(os.Stdout)
@@ -215,26 +212,6 @@ func (s *integrationMDMTestSuite) SetupSuite() {
SoftwareInstallStore: softwareInstallerStore,
BootstrapPackageStore: bootstrapPackageStore,
StartCronSchedules: []TestNewScheduleFunc{
- func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
- return func() (fleet.CronSchedule, error) {
- const name = string(fleet.CronAppleMDMDEPProfileAssigner)
- logger := cronLog
- fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger)
- depSchedule = schedule.New(
- ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
- schedule.WithLogger(logger),
- schedule.WithJob("dep_syncer", func(ctx context.Context) error {
- if s.onDEPScheduleDone != nil {
- defer s.onDEPScheduleDone()
- }
- err := fleetSyncer.RunAssigner(ctx)
- require.NoError(s.T(), err)
- return err
- }),
- )
- return depSchedule, nil
- }
- },
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronMDMAppleProfileManager)
@@ -323,7 +300,6 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.fleetCfg = fleetCfg
s.pushProvider = pushProvider
s.depStorage = depStorage
- s.depSchedule = depSchedule
s.integrationsSchedule = integrationsSchedule
s.profileSchedule = profileSchedule
s.mdmStorage = mdmStorage
@@ -579,7 +555,6 @@ func (s *integrationMDMTestSuite) SetupSuite() {
// enable MDM flows
s.appleCoreCertsSetup()
- s.enableABM()
s.T().Cleanup(fleetdmSrv.Close)
s.T().Cleanup(s.appleVPPConfigSrv.Close)
@@ -674,6 +649,14 @@ func (s *integrationMDMTestSuite) TearDownTest() {
_, err := tx.ExecContext(ctx, "DELETE FROM host_mdm;")
return err
})
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, "DELETE FROM abm_tokens;")
+ return err
+ })
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;")
+ return err
+ })
}
func (s *integrationMDMTestSuite) mockDEPResponse(orgName string, handler http.Handler) {
@@ -805,8 +788,6 @@ func (s *integrationMDMTestSuite) TestGetBootstrapToken() {
})
}
-const defaultOrgName = "fleet"
-
func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
t := s.T()
@@ -818,22 +799,36 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
require.Equal(t, "Fleet", mdmResp.CommonName)
require.NotZero(t, mdmResp.RenewDate)
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- switch r.URL.Path {
- case "/session":
- _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
- case "/account":
- _, _ = w.Write([]byte(`{"admin_id": "abc", "org_name": "fleet"}`))
- }
- }))
- var getAppleBMResp getAppleBMResponse
- s.DoJSON("GET", "/api/latest/fleet/abm", nil, http.StatusOK, &getAppleBMResp)
- require.NoError(t, getAppleBMResp.Err)
- require.Equal(t, "abc", getAppleBMResp.AppleID)
- require.Equal(t, "fleet", getAppleBMResp.OrgName)
- require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
- require.Empty(t, getAppleBMResp.DefaultTeam)
+ // set up multiple ABM tokens with different org names
+ defaultOrgName := "fleet_test"
+ s.enableABM(defaultOrgName)
+ tmOrgName := t.Name()
+ s.enableABM(tmOrgName)
+
+ var tokensResp listABMTokensResponse
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+
+ // for t.Name()
+ tok := s.getABMTokenByName(defaultOrgName, tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.False(t, tok.TermsExpired)
+ require.Equal(t, "abc", tok.AppleID)
+ require.Equal(t, defaultOrgName, tok.OrganizationName)
+ require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeam.Name)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name)
+
+ // for tmOrgName
+ tok = s.getABMTokenByName(tmOrgName, tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.False(t, tok.TermsExpired)
+ require.Equal(t, "abc", tok.AppleID)
+ require.Equal(t, tmOrgName, tok.OrganizationName)
+ require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeam.Name)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name)
+ require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name)
// create a new team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
@@ -841,29 +836,58 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
Description: "desc",
})
require.NoError(t, err)
- // set the default bm assignment to that team
+ // set the default bm assignment for that token to that team
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
- "apple_bm_default_team": %q
+ "apple_business_manager": [{
+ "organization_name": %q,
+ "macos_team": %q,
+ "ios_team": %q,
+ "ipados_team": %q
+ }]
}
- }`, tm.Name)), http.StatusOK, &acResp)
+ }`, tmOrgName, tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
+ t.Cleanup(func() {
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "apple_business_manager": []
+ }
+ }`), http.StatusOK, &acResp)
+ })
+
+ // try again, this time we get team assignments in the response
+ tokensResp = listABMTokensResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+
+ tok = s.getABMTokenByName(tmOrgName, tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.False(t, tok.TermsExpired)
+ require.Equal(t, "abc", tok.AppleID)
+ require.Equal(t, tmOrgName, tok.OrganizationName)
+ require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL)
+ require.Equal(t, tm.Name, tok.MacOSTeam.Name)
+ require.Equal(t, tm.Name, tok.IOSTeam.Name)
+ require.Equal(t, tm.Name, tok.IPadOSTeam.Name)
+}
+
+func (s *integrationMDMTestSuite) getABMTokenByName(orgName string, tokens []*fleet.ABMToken) *fleet.ABMToken {
+ for _, tok := range tokens {
+ if tok.OrganizationName == orgName {
+ return tok
+ }
+ }
- // try again, this time we get a default team in the response
- getAppleBMResp = getAppleBMResponse{}
- s.DoJSON("GET", "/api/latest/fleet/abm", nil, http.StatusOK, &getAppleBMResp)
- require.NoError(t, getAppleBMResp.Err)
- require.Equal(t, "abc", getAppleBMResp.AppleID)
- require.Equal(t, "fleet", getAppleBMResp.OrgName)
- require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
+ return nil
}
func (s *integrationMDMTestSuite) TestABMExpiredToken() {
t := s.T()
- // FIXME
- t.Skip()
+
+ s.enableABM(t.Name())
+
var returnType string
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch returnType {
case "not_signed":
w.WriteHeader(http.StatusForbidden)
@@ -882,27 +906,43 @@ func (s *integrationMDMTestSuite) TestABMExpiredToken() {
config := s.getConfig()
require.False(t, config.MDM.AppleBMTermsExpired)
+ ctx := context.Background()
+ fleetSyncer := apple_mdm.NewDEPService(s.ds, s.depStorage, s.logger)
+
// not signed error flips the AppleBMTermsExpired flag
returnType = "not_signed"
- res := s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusBadRequest)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "DEP auth error: 403 Forbidden")
-
+ err := fleetSyncer.RunAssigner(ctx)
+ require.ErrorContains(t, err, "T_C_NOT_SIGNED")
+ var tokensResp listABMTokensResponse
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+ tok := s.getABMTokenByName(t.Name(), tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.True(t, tok.TermsExpired)
config = s.getConfig()
require.True(t, config.MDM.AppleBMTermsExpired)
// a successful call clears it
returnType = "success"
- s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusOK)
+ err = fleetSyncer.RunAssigner(ctx)
+ require.NoError(t, err)
+ tokensResp = listABMTokensResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+ tok = s.getABMTokenByName(t.Name(), tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.False(t, tok.TermsExpired)
config = s.getConfig()
require.False(t, config.MDM.AppleBMTermsExpired)
- // an unauthorized call returns 400 but does not flip the terms expired flag
+ // an unauthorized call does not flip the terms expired flag
returnType = "unauthorized"
- res = s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusBadRequest)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Apple Business Manager certificate or server token is invalid")
+ err = fleetSyncer.RunAssigner(ctx)
+ require.ErrorContains(t, err, "DEP auth error")
+ tokensResp = listABMTokensResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+ tok = s.getABMTokenByName(t.Name(), tokensResp.Tokens)
+ require.NotNil(t, tok)
+ require.False(t, tok.TermsExpired)
config = s.getConfig()
require.False(t, config.MDM.AppleBMTermsExpired)
@@ -1378,8 +1418,8 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
// 3 profiles added + 1 profile with fleetd configuration + 1 root CA config
require.Len(t, *hostResp.Host.MDM.Profiles, 5)
- // try to unenroll the host, fails since the host doesn't respond
- s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusGatewayTimeout)
+ // returns success, but this is effectively a no-op because the host isn't enrolled yet.
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusOK)
// we're going to modify this mock, make sure we restore its default
originalPushMock := s.pushProvider.PushFunc
@@ -2632,13 +2672,28 @@ func (s *integrationMDMTestSuite) TestFleetdConfiguration() {
require.NoError(t, err)
s.assertConfigProfilesByIdentifier(&tm.ID, mobileconfig.FleetdConfigPayloadIdentifier, false)
+ // upload an ABM token
+ s.enableABM(t.Name())
+
// set the default bm assignment to that team
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
- "apple_bm_default_team": %q
+ "apple_business_manager": [{
+ "organization_name": %q,
+ "macos_team": %q,
+ "ios_team": %q,
+ "ipados_team": %q
+ }]
}
- }`, tm.Name)), http.StatusOK, &acResp)
+ }`, t.Name(), tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp)
+ t.Cleanup(func() {
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "apple_business_manager": []
+ }
+ }`), http.StatusOK, &acResp)
+ })
// the team doesn't have any enroll secrets yet, a profile is created using the global enroll secret
s.awaitTriggerProfileSchedule(t)
@@ -3039,8 +3094,10 @@ func (s *integrationMDMTestSuite) TestBootstrapPackage() {
func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
t := s.T()
- // TODO: fixme
- t.Skip()
+
+ abmOrgName := "abm_org"
+ s.enableABM(abmOrgName)
+
pkg, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
@@ -3153,9 +3210,8 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
})
require.NoError(t, err)
- ch := make(chan bool)
mockRespDevices := noTeamDevices
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(abmOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
@@ -3176,7 +3232,6 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
err := encoder.Encode(godep.DeviceResponse{Devices: depResp})
require.NoError(t, err)
case "/profile/devices":
- ch <- true
_, _ = w.Write([]byte(`{}`))
default:
_, _ = w.Write([]byte(`{}`))
@@ -3184,9 +3239,7 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
}))
// trigger a dep sync
- _, err = s.depSchedule.Trigger()
- require.NoError(t, err)
- <-ch
+ s.runDEPSchedule()
var summaryResp getMDMAppleBootstrapPackageSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/bootstrap/summary", nil, http.StatusOK, &summaryResp)
@@ -3200,15 +3253,25 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
- "apple_bm_default_team": %q
+ "apple_business_manager": [{
+ "organization_name": %q,
+ "macos_team": %q,
+ "ios_team": %q,
+ "ipados_team": %q
+ }]
}
- }`, team.Name)), http.StatusOK, &acResp)
+ }`, abmOrgName, team.Name, team.Name, team.Name)), http.StatusOK, &acResp)
+ t.Cleanup(func() {
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "apple_business_manager": []
+ }
+ }`), http.StatusOK, &acResp)
+ })
// trigger a dep sync
mockRespDevices = teamDevices
- _, err = s.depSchedule.Trigger()
- require.NoError(t, err)
- <-ch
+ s.runDEPSchedule()
summaryResp = getMDMAppleBootstrapPackageSummaryResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/bootstrap/summary?team_id=%d", team.ID), nil, http.StatusOK, &summaryResp)
@@ -3423,8 +3486,6 @@ func (s *integrationMDMTestSuite) TestEULA() {
func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
t := s.T()
- // FIXME
- t.Skip()
h := createHostAndDeviceToken(t, s.ds, "good-token")
@@ -3495,8 +3556,10 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
require.False(t, webhookCalled)
+ s.enableABM(t.Name())
+
// simulate that the device is assigned to Fleet in ABM
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
@@ -3601,8 +3664,6 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() {
t := s.T()
- // FIXME
- t.Skip()
h := createHostAndDeviceToken(t, s.ds, "good-token")
@@ -3636,8 +3697,9 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() {
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
require.False(t, webhookCalled)
+ s.enableABM(t.Name())
// simulate that the device is assigned to Fleet in ABM
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
@@ -3686,7 +3748,7 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() {
func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
t := s.T()
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
@@ -4426,7 +4488,9 @@ func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint
}
var cfgProfs []*fleet.MDMWindowsConfigProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID)
+ return sqlx.SelectContext(context.Background(), q, &cfgProfs,
+ `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`,
+ teamID)
})
label := "exist"
@@ -4752,14 +4816,13 @@ func (s *integrationMDMTestSuite) setTokenForTest(t *testing.T, email, password
func (s *integrationMDMTestSuite) TestSSO() {
t := s.T()
- // FIXME
- t.Skip()
mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
SCEPChallenge: s.scepChallenge,
}, "MacBookPro16,1")
+ s.enableABM(t.Name())
var lastSubmittedProfile *godep.Profile
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
@@ -5289,8 +5352,6 @@ func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enr
func (s *integrationMDMTestSuite) TestMDMMigration() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
// enable migration
@@ -5299,6 +5360,8 @@ func (s *integrationMDMTestSuite) TestMDMMigration() {
"mdm": { "macos_migration": { "enable": true, "mode": "voluntary", "webhook_url": "https://example.com" } }
}`), http.StatusOK, &acResp)
+ s.enableABM(t.Name())
+
checkMigrationResponses := func(host *fleet.Host, token string) {
getDesktopResp := fleetDesktopResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
@@ -5321,7 +5384,7 @@ func (s *integrationMDMTestSuite) TestMDMMigration() {
// simulate that the device is assigned to Fleet in ABM
profileAssignmentStatusResponse := fleet.DEPAssignProfileResponseSuccess
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
@@ -7901,11 +7964,10 @@ func (s *integrationMDMTestSuite) runWorker() {
}
func (s *integrationMDMTestSuite) runDEPSchedule() {
- ch := make(chan bool)
- s.onDEPScheduleDone = func() { close(ch) }
- _, err := s.depSchedule.Trigger()
+ ctx := context.Background()
+ fleetSyncer := apple_mdm.NewDEPService(s.ds, s.depStorage, s.logger)
+ err := fleetSyncer.RunAssigner(ctx)
require.NoError(s.T(), err)
- <-ch
}
func (s *integrationMDMTestSuite) runIntegrationsSchedule() {
@@ -8638,20 +8700,38 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() {
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent)
}
-func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
+func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
t := s.T()
- // FIXME
- t.Skip()
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
+ s.enableABM(t.Name())
var lastSubmittedProfile *godep.Profile
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
+ case "/server/devices", "/devices/sync":
+ encoder := json.NewEncoder(w)
+ err := encoder.Encode(godep.DeviceResponse{
+ Devices: []godep.Device{
+ {
+ SerialNumber: "FAKE-1",
+ Model: "Mac Mini",
+ OS: "osx",
+ OpType: "added",
+ },
+ {
+ SerialNumber: "FAKE-2",
+ Model: "Mac Mini",
+ OS: "osx",
+ OpType: "added",
+ },
+ },
+ })
+ require.NoError(t, err)
case "/profile":
lastSubmittedProfile = &godep.Profile{}
rawProfile, err := io.ReadAll(r.Body)
@@ -8670,6 +8750,9 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
}
}))
+ // run once to ingest the devices
+ s.runDEPSchedule()
+
// disable first to make sure we start in the desired state
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
@@ -8698,6 +8781,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
// assign the DEP profile and assert that contains the right values for the URL
s.runWorker()
+ require.NotNil(t, lastSubmittedProfile)
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/mdm/sso")
// trying to set a custom configuration_web_url fails because end user authentication is enabled
@@ -8727,6 +8811,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
// assign the DEP profile and assert that contains the right values for the URL
s.runWorker()
+ require.NotNil(t, lastSubmittedProfile)
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
// setting a custom configuration_web_url succeeds because user authentication is disabled
@@ -8739,6 +8824,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
// assign the DEP profile and assert that contains the right values for the URL
s.runWorker()
+ require.NotNil(t, lastSubmittedProfile)
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, "https://foo.example.com")
// try to enable end user auth again, it fails because configuration_web_url is set
@@ -8775,8 +8861,13 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
require.Len(t, applyResp.TeamIDsByName, 1)
teamID := applyResp.TeamIDsByName[t.Name()]
+ // transfer a host to the team to ensure all ABM calls are made
+ h, err := s.ds.HostByIdentifier(context.Background(), "FAKE-1")
+ require.NoError(t, err)
+ s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &addHostsToTeamResponse{})
+
// re-set the global state to configure MDM SSO
- err := s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil)
+ err = s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil)
require.NoError(t, err)
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
@@ -9148,12 +9239,9 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() {
func (s *integrationMDMTestSuite) TestABMAssetManagement() {
t := s.T()
- // FIXME
- t.Skip()
ctx := context.Background()
- // ensure enable ABM again for other tests
- t.Cleanup(s.enableABM)
+ s.enableABM(t.Name())
// Validate error when server private key not set
testSetEmptyPrivateKey = true
@@ -9169,21 +9257,21 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() {
require.Nil(t, abmResp.Err)
require.NotEmpty(t, abmResp.PublicKey)
+ var tokensResp listABMTokensResponse
+ s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp)
+ tok := s.getABMTokenByName(t.Name(), tokensResp.Tokens)
+
// disable ABM
- s.Do("DELETE", "/api/latest/fleet/abm_tokens", nil, http.StatusNoContent)
- assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
- fleet.MDMAssetABMCert,
- fleet.MDMAssetABMKey,
- fleet.MDMAssetABMTokenDeprecated,
- })
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/abm_tokens/%d", tok.ID), nil, http.StatusNoContent)
+ tok, err := s.ds.GetABMTokenByOrgName(ctx, t.Name())
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
- require.Nil(t, assets)
+ require.Nil(t, tok)
- // try to upload a token without a keypair
- s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please generate a keypair first.")
+ // try to upload an invalid token
+ s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please provide a valid token from Apple Business Manager")
- // enable ABM again, creates a new keypair because the previous one was deleted
+ // enable ABM again
var newABMResp generateABMKeyPairResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &newABMResp)
require.Nil(t, newABMResp.Err)
@@ -9191,9 +9279,8 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() {
block, _ := pem.Decode(newABMResp.PublicKey)
require.NotNil(t, block)
require.Equal(t, "CERTIFICATE", block.Type)
- require.NotEqual(t, abmResp.PublicKey, newABMResp.PublicKey)
- // as long as the certs are not deleted, we should return the same values to support renewing the token
+ // we should always return the same values to support renewing the token
var renewABMResp generateABMKeyPairResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &renewABMResp)
require.Nil(t, renewABMResp.Err)
@@ -9201,10 +9288,10 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() {
require.Equal(t, renewABMResp.PublicKey, newABMResp.PublicKey)
// simulate a renew flow
- s.enableABM()
+ s.enableABM(t.Name())
}
-func (s *integrationMDMTestSuite) enableABM() {
+func (s *integrationMDMTestSuite) enableABM(orgName string) {
t := s.T()
var abmResp generateABMKeyPairResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &abmResp)
@@ -9247,7 +9334,7 @@ func (s *integrationMDMTestSuite) enableABM() {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/account":
- _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, defaultOrgName)))
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName)))
}
}))
@@ -9270,9 +9357,26 @@ func (s *integrationMDMTestSuite) enableABM() {
require.Len(t, assets, 2)
require.Equal(t, abmResp.PublicKey, assets[fleet.MDMAssetABMCert].Value)
- tok, err := s.ds.GetABMTokenByOrgName(ctx, defaultOrgName)
+ tok, err := s.ds.GetABMTokenByOrgName(ctx, orgName)
+ require.NoError(t, err)
+ require.Equal(t, orgName, tok.OrganizationName)
+
+ // do a dummy call so the nanodep client updates the org name in
+ // nano_dep_names, and leave the mock set with a dummy response
+ s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ switch r.URL.Path {
+ case "/session":
+ _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
+ case "/account":
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName)))
+ default:
+ _, _ = w.Write([]byte(`{}`))
+ }
+ }))
+ depClient := apple_mdm.NewDEPClient(s.depStorage, s.ds, s.logger)
+ _, err = depClient.AccountDetail(ctx, orgName)
require.NoError(t, err)
- require.Equal(t, defaultOrgName, tok.OrganizationName)
}
func (s *integrationMDMTestSuite) appleCoreCertsSetup() {
@@ -9370,8 +9474,6 @@ func (s *integrationMDMTestSuite) uploadABMToken(encryptedToken []byte, expected
func (s *integrationMDMTestSuite) TestSilentMigrationGotchas() {
t := s.T()
ctx := context.Background()
- // FIXME
- t.Skip()
host := createOrbitEnrolledHost(t, "darwin", t.Name(), s.ds)
// set the host as enrolled in a third-party MDM
@@ -9385,7 +9487,8 @@ func (s *integrationMDMTestSuite) TestSilentMigrationGotchas() {
require.False(t, *hostResp.Host.MDM.ConnectedToFleet)
// simulate that the device is assigned to Fleet in ABM
- s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.enableABM(t.Name())
+ s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
@@ -10116,8 +10219,6 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
func (s *integrationMDMTestSuite) TestVPPApps() {
t := s.T()
- // FIXME
- t.Skip()
// Invalid token
t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?invalidToken")
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.", nil)
@@ -10418,7 +10519,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
var vppRes uploadVPPTokenResponse
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSONBad))), http.StatusAccepted, "", &vppRes)
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, 99}}, http.StatusBadRequest, &resPatchVPP)
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, 99}}, http.StatusUnprocessableEntity, &resPatchVPP)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
diff --git a/server/service/mdm.go b/server/service/mdm.go
index 1b9e28c457ce..294d503d81ef 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -30,7 +30,6 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
- "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
@@ -567,13 +566,14 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte
var apnsErr *apple_mdm.APNSDeliveryError
var mysqlErr *mysql.MySQLError
if errors.As(err, &apnsErr) {
- if len(apnsErr.FailedUUIDs) < len(deviceIDs) {
+ failedUUIDs := apnsErr.FailedUUIDs()
+ if len(failedUUIDs) < len(deviceIDs) {
// some hosts properly received the command, so return success, with the list
// of failed uuids.
return &fleet.CommandEnqueueResult{
CommandUUID: cmd.CommandUUID,
RequestType: cmd.Command.RequestType,
- FailedUUIDs: apnsErr.FailedUUIDs,
+ FailedUUIDs: failedUUIDs,
}, nil
}
// push failed for all hosts
@@ -1167,7 +1167,7 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU
}
// cannot use the profile ID as it is now deleted
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
@@ -1412,7 +1412,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
return nil, ctxerr.Wrap(ctx, err)
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
+ if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
@@ -1600,7 +1600,8 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
- if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil {
+ var profUpdates fleet.MDMProfilesUpdates
+ if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil {
return ctxerr.Wrap(ctx, err, "setting config profiles")
}
@@ -1609,7 +1610,8 @@ func (svc *Service) BatchSetMDMProfiles(
for _, p := range windowsProfiles {
winProfUUIDs = append(winProfUUIDs, p.ProfileUUID)
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil); err != nil {
+ winUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil)
+ if err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles")
}
@@ -1618,33 +1620,42 @@ func (svc *Service) BatchSetMDMProfiles(
for _, p := range appleProfiles {
appleProfUUIDs = append(appleProfUUIDs, p.ProfileUUID)
}
- if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil); err != nil {
+ appleUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil)
+ if err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles")
}
+ updates := fleet.MDMProfilesUpdates{
+ AppleConfigProfile: profUpdates.AppleConfigProfile || winUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile,
+ WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile || appleUpdates.WindowsConfigProfile,
+ AppleDeclaration: profUpdates.AppleDeclaration || winUpdates.AppleDeclaration || appleUpdates.AppleDeclaration,
+ }
- // TODO(roberto): should we generate activities only of any profiles were
- // changed? this is the existing behavior for macOS profiles so I'm
- // leaving it as-is for now.
- if err := svc.NewActivity(
- ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{
- TeamID: tmID,
- TeamName: tmName,
- }); err != nil {
- return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile")
+ if updates.AppleConfigProfile {
+ if err := svc.NewActivity(
+ ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{
+ TeamID: tmID,
+ TeamName: tmName,
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile")
+ }
}
- if err := svc.NewActivity(
- ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{
- TeamID: tmID,
- TeamName: tmName,
- }); err != nil {
- return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile")
+ if updates.WindowsConfigProfile {
+ if err := svc.NewActivity(
+ ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{
+ TeamID: tmID,
+ TeamName: tmName,
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile")
+ }
}
- if err := svc.NewActivity(
- ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{
- TeamID: tmID,
- TeamName: tmName,
- }); err != nil {
- return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations")
+ if updates.AppleDeclaration {
+ if err := svc.NewActivity(
+ ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{
+ TeamID: tmID,
+ TeamName: tmName,
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations")
+ }
}
return nil
@@ -2531,335 +2542,3 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error {
return svc.ds.SaveAppConfig(ctx, appCfg)
}
-
-////////////////////////////////////////////////////////////////////////////////
-// POST /api/_version_/vpp_tokens
-////////////////////////////////////////////////////////////////////////////////
-
-type uploadVPPTokenRequest struct {
- File *multipart.FileHeader
-}
-
-func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
- decoded := uploadVPPTokenRequest{}
-
- err := r.ParseMultipartForm(512 * units.MiB)
- if err != nil {
- return nil, &fleet.BadRequestError{
- Message: "failed to parse multipart form",
- InternalErr: err,
- }
- }
-
- if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 {
- return nil, &fleet.BadRequestError{
- Message: "token multipart field is required",
- InternalErr: err,
- }
- }
-
- decoded.File = r.MultipartForm.File["token"][0]
-
- return &decoded, nil
-}
-
-type uploadVPPTokenResponse struct {
- Err error `json:"error,omitempty"`
- Token *fleet.VPPTokenDB `json:"token,omitempty"`
-}
-
-func (r uploadVPPTokenResponse) Status() int { return http.StatusAccepted }
-
-func (r uploadVPPTokenResponse) error() error {
- return r.Err
-}
-
-func uploadVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
- req := request.(*uploadVPPTokenRequest)
- file, err := req.File.Open()
- if err != nil {
- return uploadVPPTokenResponse{Err: err}, nil
- }
- defer file.Close()
-
- tok, err := svc.UploadVPPToken(ctx, file)
- if err != nil {
- return uploadVPPTokenResponse{Err: err}, nil
- }
-
- return uploadVPPTokenResponse{Token: tok}, nil
-}
-
-func (svc *Service) UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*fleet.VPPTokenDB, error) {
- if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
- return nil, err
- }
-
- privateKey := svc.config.Server.PrivateKey
- if testSetEmptyPrivateKey {
- privateKey = ""
- }
-
- if len(privateKey) == 0 {
- return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
- }
-
- if token == nil {
- return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
- }
-
- tokenBytes, err := io.ReadAll(token)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "reading VPP token")
- }
-
- locName, err := vpp.GetConfig(string(tokenBytes))
- if err != nil {
- var vppErr *vpp.ErrorResponse
- if errors.As(err, &vppErr) {
- // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
- if vppErr.ErrorNumber == 9622 {
- return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
- }
- }
- return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple")
- }
-
- data := fleet.VPPTokenData{
- Token: string(tokenBytes),
- Location: locName,
- }
-
- tok, err := svc.ds.InsertVPPToken(ctx, &data)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "writing VPP token to db")
- }
-
- if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEnabledVPP{
- Location: locName,
- }); err != nil {
- return nil, ctxerr.Wrap(ctx, err, "create activity for upload VPP token")
- }
-
- return tok, nil
-}
-
-////////////////////////////////////////////////////
-// PATCH /api/_version_/fleet/vpp_tokens/%d/renew //
-////////////////////////////////////////////////////
-
-type patchVPPTokenRenewRequest struct {
- ID uint `url:"id"`
- File *multipart.FileHeader
-}
-
-func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
- decoded := patchVPPTokenRenewRequest{}
-
- err := r.ParseMultipartForm(512 * units.MiB)
- if err != nil {
- return nil, &fleet.BadRequestError{
- Message: "failed to parse multipart form",
- InternalErr: err,
- }
- }
-
- if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 {
- return nil, &fleet.BadRequestError{
- Message: "token multipart field is required",
- InternalErr: err,
- }
- }
-
- decoded.File = r.MultipartForm.File["token"][0]
-
- return &decoded, nil
-}
-
-type patchVPPTokenRenewResponse struct {
- Err error `json:"error,omitempty"`
- Token *fleet.VPPTokenDB `json:"token,omitempty"`
-}
-
-func (r patchVPPTokenRenewResponse) Status() int { return http.StatusAccepted }
-
-func (r patchVPPTokenRenewResponse) error() error {
- return r.Err
-}
-
-func patchVPPTokenRenewEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
- req := request.(*patchVPPTokenRenewRequest)
- file, err := req.File.Open()
- if err != nil {
- return patchVPPTokenRenewResponse{Err: err}, nil
- }
- defer file.Close()
-
- tok, err := svc.UpdateVPPToken(ctx, req.ID, file)
- if err != nil {
- return patchVPPTokenRenewResponse{Err: err}, nil
- }
-
- return patchVPPTokenRenewResponse{Token: tok}, nil
-}
-
-func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) {
- if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
- return nil, err
- }
-
- privateKey := svc.config.Server.PrivateKey
- if testSetEmptyPrivateKey {
- privateKey = ""
- }
-
- if len(privateKey) == 0 {
- return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
- }
-
- if token == nil {
- return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
- }
-
- tokenBytes, err := io.ReadAll(token)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "reading VPP token")
- }
-
- locName, err := vpp.GetConfig(string(tokenBytes))
- if err != nil {
- var vppErr *vpp.ErrorResponse
- if errors.As(err, &vppErr) {
- // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
- if vppErr.ErrorNumber == 9622 {
- return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
- }
- }
- return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple")
- }
-
- data := fleet.VPPTokenData{
- Token: string(tokenBytes),
- Location: locName,
- }
-
- tok, err := svc.ds.UpdateVPPToken(ctx, tokenID, &data)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "updating vpp token")
- }
-
- return tok, nil
-}
-
-////////////////////////////////////////////////////
-// PATCH /api/_version_/fleet/vpp_tokens/%d/teams //
-////////////////////////////////////////////////////
-
-type patchVPPTokensTeamsRequest struct {
- ID uint `url:"id"`
- TeamIDs []uint `json:"teams"`
-}
-
-type patchVPPTokensTeamsResponse struct {
- Token *fleet.VPPTokenDB `json:"token,omitempty"`
- Err error `json:"error,omitempty"`
-}
-
-func (r patchVPPTokensTeamsResponse) error() error { return r.Err }
-
-func patchVPPTokensTeams(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
- req := request.(*patchVPPTokensTeamsRequest)
-
- tok, err := svc.UpdateVPPTokenTeams(ctx, req.ID, req.TeamIDs)
- if err != nil {
- return patchVPPTokensTeamsResponse{Err: err}, nil
- }
- return patchVPPTokensTeamsResponse{Token: tok}, nil
-}
-
-func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) {
- if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
- return nil, err
- }
-
- tok, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, teamIDs)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "updating vpp token team")
- }
-
- return tok, nil
-}
-
-///////////////////////////////////////////////
-// DELETE /api/_version_/fleet/vpp_tokens/%d //
-///////////////////////////////////////////////
-
-type getVPPTokensRequest struct{}
-
-type getVPPTokensResponse struct {
- Tokens []*fleet.VPPTokenDB `json:"vpp_tokens"`
- Err error `json:"error,omitempty"`
-}
-
-func (r getVPPTokensResponse) error() error { return r.Err }
-
-func getVPPTokens(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
- tokens, err := svc.GetVPPTokens(ctx)
- if err != nil {
- return getVPPTokensResponse{Err: err}, nil
- }
-
- if tokens == nil {
- tokens = []*fleet.VPPTokenDB{}
- }
-
- return getVPPTokensResponse{Tokens: tokens}, nil
-}
-
-func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
- if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil {
- return nil, err
- }
-
- return svc.ds.ListVPPTokens(ctx)
-}
-
-type deleteVPPTokenRequest struct {
- ID uint `url:"id"`
-}
-
-type deleteVPPTokenResponse struct {
- Err error `json:"error,omitempty"`
-}
-
-func (r deleteVPPTokenResponse) error() error { return r.Err }
-
-func (r deleteVPPTokenResponse) Status() int { return http.StatusNoContent }
-
-func deleteVPPToken(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
- req := request.(*deleteVPPTokenRequest)
-
- err := svc.DeleteVPPToken(ctx, req.ID)
- if err != nil {
- return deleteVPPTokenResponse{Err: err}, nil
- }
-
- return deleteVPPTokenResponse{}, nil
-}
-
-func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error {
- if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
- return err
- }
- tok, err := svc.ds.GetVPPToken(ctx, tokenID)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "getting vpp token")
- }
- if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDisabledVPP{
- Location: tok.Location,
- }); err != nil {
- return ctxerr.Wrap(ctx, err, "create activity for delete VPP token")
- }
-
- return svc.ds.DeleteVPPToken(ctx, tokenID)
-}
diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go
index 435126d2b1e5..3a626af0277d 100644
--- a/server/service/mdm_test.go
+++ b/server/service/mdm_test.go
@@ -70,8 +70,6 @@ func TestGetMDMApple(t *testing.T) {
}
func TestMDMAppleAuthorization(t *testing.T) {
- // FIXME
- t.Skip()
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
@@ -125,6 +123,16 @@ func TestMDMAppleAuthorization(t *testing.T) {
return nil
}
+ ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
+ return nil, nil
+ }
+ ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
+ return nil, nil
+ }
+ ds.GetVPPTokenFunc = func(ctx context.Context, id uint) (*fleet.VPPTokenDB, error) {
+ return nil, ¬FoundErr{}
+ }
+
ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return nil }
// use a custom implementation of checkAuthErr as the service call will fail
@@ -1068,8 +1076,10 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) {
ds.ListMDMConfigProfilesFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) {
return nil, nil, nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string,
+ hostUUIDs []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
checkShouldFail := func(t *testing.T, err error, shouldFail bool) {
@@ -1142,8 +1152,10 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) {
cp.ProfileUUID = uuid.New().String()
return &cp, nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string,
+ hostUUIDs []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
cases := []struct {
@@ -1225,16 +1237,20 @@ func TestMDMBatchSetProfiles(t *testing.T) {
ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: id, Name: "team"}, nil
}
- ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error {
- return nil
+ ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile,
+ winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.NewActivityFunc = func(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string,
+ hostUUIDs []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
testCases := []struct {
diff --git a/server/service/orbit.go b/server/service/orbit.go
index 394230c602dc..e894f8a1577a 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -998,11 +998,31 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
return ctxerr.Wrap(ctx, err, "get host software installation result information")
}
+ // Self-Service packages will have a nil author for the activity.
var user *fleet.User
- 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")
+ if !hsi.SelfService {
+ if hsi.UserID != nil {
+ user, err = svc.ds.UserByID(ctx, *hsi.UserID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get host software installation user")
+ }
+ } else {
+ // hsi.UserID can be nil if the user was deleted and/or if the installation was
+ // triggered by Fleet (policy automation). Thus we set the author of the installation
+ // to be the user that uploaded the package (by design).
+ var userID uint
+ if hsi.SoftwareInstallerUserID != nil {
+ userID = *hsi.SoftwareInstallerUserID
+ }
+ // If there's no name or email then this may be a package uploaded
+ // before we added authorship to uploaded packages.
+ if hsi.SoftwareInstallerUserName != "" && hsi.SoftwareInstallerUserEmail != "" {
+ user = &fleet.User{
+ ID: userID,
+ Name: hsi.SoftwareInstallerUserName,
+ Email: hsi.SoftwareInstallerUserEmail,
+ }
+ }
}
}
diff --git a/server/service/osquery.go b/server/service/osquery.go
index c4112f7d0d90..689c1e776ea8 100644
--- a/server/service/osquery.go
+++ b/server/service/osquery.go
@@ -81,7 +81,7 @@ func (svc *Service) AuthenticateHost(ctx context.Context, nodeKey string) (*flee
case err == nil:
// OK
case fleet.IsNotFound(err):
- return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key: " + nodeKey)
+ return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key")
default:
return nil, false, newOsqueryError("authentication error: " + err.Error())
}
@@ -1008,6 +1008,10 @@ func (svc *Service) SubmitDistributedQueryResults(
logging.WithErr(ctx, err)
}
+ if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil {
+ logging.WithErr(ctx, err)
+ }
+
// filter policy results for webhooks
var policyIDs []uint
if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) {
@@ -1038,6 +1042,7 @@ func (svc *Service) SubmitDistributedQueryResults(
}()
}
}
+
// NOTE(mna): currently, failing policies webhook wouldn't see the new
// flipped policies on the next run if async processing is enabled and the
// collection has not been done yet (not persisted in mysql). Should
@@ -1606,6 +1611,141 @@ func (svc *Service) registerFlippedPolicies(ctx context.Context, hostID uint, ho
return nil
}
+func (svc *Service) processSoftwareForNewlyFailingPolicies(
+ ctx context.Context,
+ hostID uint,
+ hostTeamID *uint,
+ hostPlatform string,
+ hostOrbitNodeKey *string,
+ incomingPolicyResults map[uint]*bool,
+) error {
+ if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" {
+ // We do not want to queue software installations on vanilla osquery hosts.
+ return nil
+ }
+ if hostTeamID == nil {
+ // TODO(lucas): Support hosts in "No team".
+ return nil
+ }
+
+ // Filter out results that are not failures (we are only interested on failing policies,
+ // we don't care about passing policies or policies that failed to execute).
+ incomingFailingPolicies := make(map[uint]*bool)
+ var incomingFailingPoliciesIDs []uint
+ for policyID, policyResult := range incomingPolicyResults {
+ if policyResult != nil && !*policyResult {
+ incomingFailingPolicies[policyID] = policyResult
+ incomingFailingPoliciesIDs = append(incomingFailingPoliciesIDs, policyID)
+ }
+ }
+ if len(incomingFailingPolicies) == 0 {
+ return nil
+ }
+
+ // Get policies with associated installers for the team.
+ policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, *hostTeamID, incomingFailingPoliciesIDs)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "failed to get policies with installer")
+ }
+ if len(policiesWithInstaller) == 0 {
+ return nil
+ }
+
+ // Filter out results of policies that are not associated to installers.
+ policiesWithInstallersMap := make(map[uint]fleet.PolicySoftwareInstallerData)
+ for _, policyWithInstaller := range policiesWithInstaller {
+ policiesWithInstallersMap[policyWithInstaller.ID] = policyWithInstaller
+ }
+ policyResultsOfPoliciesWithInstallers := make(map[uint]*bool)
+ for policyID, passes := range incomingFailingPolicies {
+ if _, ok := policiesWithInstallersMap[policyID]; !ok {
+ continue
+ }
+ policyResultsOfPoliciesWithInstallers[policyID] = passes
+ }
+ if len(policyResultsOfPoliciesWithInstallers) == 0 {
+ return nil
+ }
+
+ // Get the policies associated with installers that are flipping from passing to failing on this host.
+ policyIDsOfNewlyFailingPoliciesWithInstallers, _, err := svc.ds.FlippingPoliciesForHost(
+ ctx, hostID, policyResultsOfPoliciesWithInstallers,
+ )
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "failed to get flipping policies for host")
+ }
+ if len(policyIDsOfNewlyFailingPoliciesWithInstallers) == 0 {
+ return nil
+ }
+ policyIDsOfNewlyFailingPoliciesWithInstallersSet := make(map[uint]struct{})
+ for _, policyID := range policyIDsOfNewlyFailingPoliciesWithInstallers {
+ policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyID] = struct{}{}
+ }
+
+ // Finally filter out policies with installers that are not newly failing.
+ var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData
+ for _, policyWithInstaller := range policiesWithInstaller {
+ if _, ok := policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyWithInstaller.ID]; ok {
+ failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller)
+ }
+ }
+
+ for _, failingPolicyWithInstaller := range failingPoliciesWithInstaller {
+ installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, failingPolicyWithInstaller.InstallerID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get software installer metadata by id")
+ }
+ logger := log.With(svc.logger,
+ "host_id", hostID,
+ "host_platform", hostPlatform,
+ "policy_id", failingPolicyWithInstaller.ID,
+ "software_installer_id", failingPolicyWithInstaller.InstallerID,
+ "software_title_id", installerMetadata.TitleID,
+ "software_installer_platform", installerMetadata.Platform,
+ )
+ if fleet.PlatformFromHost(hostPlatform) != installerMetadata.Platform {
+ level.Debug(logger).Log("msg", "installer platform does not match host platform")
+ continue
+ }
+ hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get host last install data")
+ }
+ // hostLastInstall.Status == nil can happen when a software is installed by Fleet and later removed.
+ if hostLastInstall != nil && hostLastInstall.Status != nil &&
+ *hostLastInstall.Status == fleet.SoftwareInstallerPending {
+ // There's a pending install for this host and installer,
+ // thus we do not queue another install request.
+ level.Debug(svc.logger).Log(
+ "msg", "found pending install request for this host and installer",
+ "pending_execution_id", hostLastInstall.ExecutionID,
+ )
+ continue
+ }
+ // NOTE(lucas): The user_id set in this software install will be NULL
+ // so this means that when generating the activity for this action
+ // (in SaveHostSoftwareInstallResult)
+ // the author will be set to the user that uploaded the software (we want this
+ // by design).
+ installUUID, err := svc.ds.InsertSoftwareInstallRequest(
+ ctx, hostID,
+ installerMetadata.InstallerID,
+ false, // Set Self-service as false because this is triggered by Fleet.
+ )
+ if err != nil {
+ return ctxerr.Wrapf(ctx, err,
+ "insert software install request: host_id=%d, software_installer_id=%d",
+ hostID, installerMetadata.InstallerID,
+ )
+ }
+ level.Debug(logger).Log(
+ "msg", "install request sent",
+ "install_uuid", installUUID,
+ )
+ }
+ return nil
+}
+
func (svc *Service) maybeDebugHost(
ctx context.Context,
host *fleet.Host,
diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go
index 8965416a642e..7ca865416ab6 100644
--- a/server/service/schedule/schedule.go
+++ b/server/service/schedule/schedule.go
@@ -167,7 +167,13 @@ func (s *Schedule) Start() {
level.Error(s.logger).Log("err", "start schedule", "details", err)
ctxerr.Handle(s.ctx, err)
}
- s.setIntervalStartedAt(prevScheduledRun.CreatedAt)
+
+ // if there is no previous run, set the start time to the current time.
+ startedAt := prevScheduledRun.CreatedAt
+ if startedAt.IsZero() {
+ startedAt = time.Now()
+ }
+ s.setIntervalStartedAt(startedAt)
initialWait := 10 * time.Second
if schedInterval := s.getSchedInterval(); schedInterval < initialWait {
diff --git a/server/service/team_policies.go b/server/service/team_policies.go
index 81ebee7d40b7..8f68ecddf1f9 100644
--- a/server/service/team_policies.go
+++ b/server/service/team_policies.go
@@ -29,6 +29,7 @@ type teamPolicyRequest struct {
Platform string `json:"platform"`
Critical bool `json:"critical" premium:"true"`
CalendarEventsEnabled bool `json:"calendar_events_enabled"`
+ SoftwareTitleID *uint `json:"software_title_id"`
}
type teamPolicyResponse struct {
@@ -40,7 +41,7 @@ func (r teamPolicyResponse) error() error { return r.Err }
func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*teamPolicyRequest)
- resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.PolicyPayload{
+ resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{
QueryID: req.QueryID,
Name: req.Name,
Query: req.Query,
@@ -49,6 +50,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
Platform: req.Platform,
Critical: req.Critical,
CalendarEventsEnabled: req.CalendarEventsEnabled,
+ SoftwareTitleID: req.SoftwareTitleID,
})
if err != nil {
return teamPolicyResponse{Err: err}, nil
@@ -56,7 +58,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
return teamPolicyResponse{Policy: resp}, nil
}
-func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.PolicyPayload) (*fleet.Policy, error) {
+func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewTeamPolicyPayload) (*fleet.Policy, error) {
if err := svc.authz.Authorize(ctx, &fleet.Policy{
PolicyData: fleet.PolicyData{
TeamID: ptr.Uint(teamID),
@@ -70,6 +72,11 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic
return nil, errors.New("user must be authenticated to create team policies")
}
+ p, err := svc.newTeamPolicyPayloadToPolicyPayload(ctx, teamID, tp)
+ if err != nil {
+ return nil, err
+ }
+
if err := p.Verify(); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("policy payload verification: %s", err),
@@ -80,6 +87,10 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic
return nil, ctxerr.Wrap(ctx, err, "creating policy")
}
+ if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "populate install_software")
+ }
+
// Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can
// rollback an action in the event of an error writing the associated activity
if err := svc.NewActivity(
@@ -95,6 +106,39 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic
return policy, nil
}
+func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet.Policy) error {
+ if p.SoftwareInstallerID == nil {
+ return nil
+ }
+ installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, *p.SoftwareInstallerID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get software installer metadata by id")
+ }
+ p.InstallSoftware = &fleet.PolicySoftwareTitle{
+ SoftwareTitleID: *installerMetadata.TitleID,
+ Name: installerMetadata.SoftwareTitle,
+ }
+ return nil
+}
+
+func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (fleet.PolicyPayload, error) {
+ softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, &teamID, p.SoftwareTitleID)
+ if err != nil {
+ return fleet.PolicyPayload{}, err
+ }
+ return fleet.PolicyPayload{
+ QueryID: p.QueryID,
+ Name: p.Name,
+ Query: p.Query,
+ Critical: p.Critical,
+ Description: p.Description,
+ Resolution: p.Resolution,
+ Platform: p.Platform,
+ CalendarEventsEnabled: p.CalendarEventsEnabled,
+ SoftwareInstallerID: softwareInstallerID,
+ }, nil
+}
+
/////////////////////////////////////////////////////////////////////////////////
// List
/////////////////////////////////////////////////////////////////////////////////
@@ -148,11 +192,27 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee
}
if mergeInherited {
- p, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts)
- return p, nil, err
+ policies, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts)
+ for i := range policies {
+ if err := svc.populatePolicyInstallSoftware(ctx, policies[i]); err != nil {
+ return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", policies[i].ID)
+ }
+ }
+ return policies, nil, err
}
- return svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts)
+ teamPolicies, inheritedPolicies, err = svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for i := range teamPolicies {
+ if err := svc.populatePolicyInstallSoftware(ctx, teamPolicies[i]); err != nil {
+ return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", teamPolicies[i].ID)
+ }
+ }
+
+ return teamPolicies, inheritedPolicies, nil
}
/////////////////////////////////////////////////////////////////////////////////
@@ -240,6 +300,10 @@ func (svc Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, po
return nil, err
}
+ if err := svc.populatePolicyInstallSoftware(ctx, teamPolicy); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "populate install_software")
+ }
+
return teamPolicy, nil
}
@@ -418,6 +482,14 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
policy.FailingHostCount = 0
policy.PassingHostCount = 0
}
+ if p.SoftwareTitleID != nil {
+ softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, teamID, p.SoftwareTitleID)
+ if err != nil {
+ return nil, err
+ }
+ policy.SoftwareInstallerID = softwareInstallerID
+ }
+
logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query)
err = svc.ds.SavePolicy(ctx, policy, removeAllMemberships, removeStats)
@@ -425,6 +497,10 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
return nil, ctxerr.Wrap(ctx, err, "saving policy")
}
+ if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "populate install_software")
+ }
+
// Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can
// rollback an action in the event of an error writing the associated activity
if err := svc.NewActivity(
@@ -440,3 +516,48 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f
return policy, nil
}
+
+func (svc *Service) deduceSoftwareInstallerIDFromTitleID(ctx context.Context, teamID *uint, softwareTitleID *uint) (*uint, error) {
+ if softwareTitleID == nil {
+ return nil, nil
+ }
+
+ // If *p.SoftwareTitleID with value 0 is used to unset the current installer from the policy.
+ if *softwareTitleID == 0 {
+ return nil, nil
+ }
+
+ if teamID == nil {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: "software_title_id cannot be set on global policies",
+ })
+ }
+
+ softwareTitle, err := svc.SoftwareTitleByID(ctx, *softwareTitleID, teamID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: fmt.Sprintf("software_title_id %d on team_id %d not found", *softwareTitleID, *teamID),
+ })
+ }
+ return nil, ctxerr.Wrap(ctx, err, "software title by id")
+ }
+ if softwareTitle.AppStoreApp != nil {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: fmt.Sprintf("software_title_id %d on team_id %d is assocated to a VPP app, only software installers are supported", *softwareTitleID, *teamID),
+ })
+ }
+ if softwareTitle.SoftwarePackage == nil {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: fmt.Sprintf("software_title_id %d on team_id %d does not have associated package", *softwareTitleID, *teamID),
+ })
+ }
+
+ //
+ // TODO(lucas): Support "No team" (softwareTitle.SoftwarePackage.TeamID == nil).
+ //
+
+ // At this point we assume *softwareTitle.SoftwarePackage.TeamID == *teamID,
+ // because SoftwareTitleByID above receives the teamID.
+ return ptr.Uint(softwareTitle.SoftwarePackage.InstallerID), nil
+}
diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go
index 5c6c25ff8ce0..551e6e567cf1 100644
--- a/server/service/team_policies_test.go
+++ b/server/service/team_policies_test.go
@@ -32,7 +32,7 @@ func TestTeamPoliciesAuth(t *testing.T) {
return nil, nil
}
ds.TeamPolicyFunc = func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) {
- return nil, nil
+ return &fleet.Policy{}, nil
}
ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) {
if id == 1 {
@@ -68,6 +68,9 @@ func TestTeamPoliciesAuth(t *testing.T) {
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
return &fleet.Team{ID: 1}, nil
}
+ ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{}, nil
+ }
testCases := []struct {
name string
@@ -149,7 +152,7 @@ func TestTeamPoliciesAuth(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
- _, err := svc.NewTeamPolicy(ctx, 1, fleet.PolicyPayload{
+ _, err := svc.NewTeamPolicy(ctx, 1, fleet.NewTeamPolicyPayload{
Name: "query1",
Query: "select 1;",
})
diff --git a/server/service/teams_test.go b/server/service/teams_test.go
index 9011e8bce73c..cf71aab79557 100644
--- a/server/service/teams_test.go
+++ b/server/service/teams_test.go
@@ -53,8 +53,9 @@ func TestTeamAuth(t *testing.T) {
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
return nil
}
- ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error {
- return nil
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ return fleet.MDMProfilesUpdates{}, nil
}
ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
return []*fleet.Host{}, nil
diff --git a/server/service/testdata/software-installers/README.md b/server/service/testdata/software-installers/README.md
new file mode 100644
index 000000000000..b5a59d9daf64
--- /dev/null
+++ b/server/service/testdata/software-installers/README.md
@@ -0,0 +1,3 @@
+# testdata
+
+- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`.
\ No newline at end of file
diff --git a/server/service/testdata/software-installers/fleet-osquery.msi b/server/service/testdata/software-installers/fleet-osquery.msi
new file mode 100644
index 000000000000..cc52ad5d4e0c
Binary files /dev/null and b/server/service/testdata/software-installers/fleet-osquery.msi differ
diff --git a/server/service/testdata/software-installers/no_version.pkg b/server/service/testdata/software-installers/no_version.pkg
new file mode 100644
index 000000000000..c649ebf17bdc
Binary files /dev/null and b/server/service/testdata/software-installers/no_version.pkg differ
diff --git a/server/service/testing_client.go b/server/service/testing_client.go
index b5232c2b1c5a..702d85b49508 100644
--- a/server/service/testing_client.go
+++ b/server/service/testing_client.go
@@ -118,12 +118,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
require.NoError(t, ts.ds.DeleteHost(ctx, host.ID))
}
- // clean up any software installers
- mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx, `DELETE FROM software_installers`)
- return err
- })
-
lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{})
require.NoError(t, err)
for _, lbl := range lbls {
@@ -161,6 +155,12 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
require.NoError(t, err)
}
+ // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
+ mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
+ return err
+ })
+
globalPolicies, err := ts.ds.ListGlobalPolicies(ctx, fleet.ListOptions{})
require.NoError(t, err)
if len(globalPolicies) > 0 {
@@ -279,6 +279,21 @@ func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStat
}
}
+func (ts *withServer) DoJSONWithoutAuth(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {
+ t := ts.s.T()
+ rawBytes, err := json.Marshal(params)
+ require.NoError(t, err)
+ resp := ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{}, queryParams...)
+ t.Cleanup(func() {
+ resp.Body.Close()
+ })
+ err = json.NewDecoder(resp.Body).Decode(v)
+ require.NoError(ts.s.T(), err)
+ if e, ok := v.(errorer); ok {
+ require.NoError(ts.s.T(), e.error())
+ }
+}
+
func (ts *withServer) getTestAdminToken() string {
testUser := testUsers["admin1"]
@@ -480,3 +495,24 @@ func (ts *withServer) lastActivityOfTypeMatches(name, details string, id uint) u
t.Fatalf("no activity of type %s found in the last %d activities", name, len(listActivities.Activities))
return 0
}
+
+func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id uint) {
+ t := ts.s.T()
+
+ var listActivities listActivitiesResponse
+ ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK,
+ &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10")
+ require.True(t, len(listActivities.Activities) > 0)
+
+ for _, act := range listActivities.Activities {
+ if act.Type == name {
+ if details != "" {
+ require.NotNil(t, act.Details)
+ assert.NotEqual(t, details, string(*act.Details))
+ }
+ if id > 0 {
+ assert.NotEqual(t, id, act.ID)
+ }
+ }
+ }
+}
diff --git a/server/service/vpp.go b/server/service/vpp.go
index 04b1ac57a3f3..c2e25eddc060 100644
--- a/server/service/vpp.go
+++ b/server/service/vpp.go
@@ -2,7 +2,11 @@ package service
import (
"context"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/server/fleet"
)
@@ -73,3 +77,239 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppT
return fleet.ErrMissingLicense
}
+
+////////////////////////////////////////////////////////////////////////////////
+// POST /api/_version_/vpp_tokens
+////////////////////////////////////////////////////////////////////////////////
+
+type uploadVPPTokenRequest struct {
+ File *multipart.FileHeader
+}
+
+func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
+ decoded := uploadVPPTokenRequest{}
+
+ err := r.ParseMultipartForm(512 * units.MiB)
+ if err != nil {
+ return nil, &fleet.BadRequestError{
+ Message: "failed to parse multipart form",
+ InternalErr: err,
+ }
+ }
+
+ if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 {
+ return nil, &fleet.BadRequestError{
+ Message: "token multipart field is required",
+ InternalErr: err,
+ }
+ }
+
+ decoded.File = r.MultipartForm.File["token"][0]
+
+ return &decoded, nil
+}
+
+type uploadVPPTokenResponse struct {
+ Err error `json:"error,omitempty"`
+ Token *fleet.VPPTokenDB `json:"token,omitempty"`
+}
+
+func (r uploadVPPTokenResponse) Status() int { return http.StatusAccepted }
+
+func (r uploadVPPTokenResponse) error() error {
+ return r.Err
+}
+
+func uploadVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*uploadVPPTokenRequest)
+ file, err := req.File.Open()
+ if err != nil {
+ return uploadVPPTokenResponse{Err: err}, nil
+ }
+ defer file.Close()
+
+ tok, err := svc.UploadVPPToken(ctx, file)
+ if err != nil {
+ return uploadVPPTokenResponse{Err: err}, nil
+ }
+
+ return uploadVPPTokenResponse{Token: tok}, nil
+}
+
+func (svc *Service) UploadVPPToken(ctx context.Context, file io.ReadSeeker) (*fleet.VPPTokenDB, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
+
+////////////////////////////////////////////////////
+// PATCH /api/_version_/fleet/vpp_tokens/%d/renew //
+////////////////////////////////////////////////////
+
+type patchVPPTokenRenewRequest struct {
+ ID uint `url:"id"`
+ File *multipart.FileHeader
+}
+
+func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
+ decoded := patchVPPTokenRenewRequest{}
+
+ err := r.ParseMultipartForm(512 * units.MiB)
+ if err != nil {
+ return nil, &fleet.BadRequestError{
+ Message: "failed to parse multipart form",
+ InternalErr: err,
+ }
+ }
+
+ if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 {
+ return nil, &fleet.BadRequestError{
+ Message: "token multipart field is required",
+ InternalErr: err,
+ }
+ }
+
+ decoded.File = r.MultipartForm.File["token"][0]
+
+ return &decoded, nil
+}
+
+type patchVPPTokenRenewResponse struct {
+ Err error `json:"error,omitempty"`
+ Token *fleet.VPPTokenDB `json:"token,omitempty"`
+}
+
+func (r patchVPPTokenRenewResponse) Status() int { return http.StatusAccepted }
+
+func (r patchVPPTokenRenewResponse) error() error {
+ return r.Err
+}
+
+func patchVPPTokenRenewEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*patchVPPTokenRenewRequest)
+ file, err := req.File.Open()
+ if err != nil {
+ return patchVPPTokenRenewResponse{Err: err}, nil
+ }
+ defer file.Close()
+
+ tok, err := svc.UpdateVPPToken(ctx, req.ID, file)
+ if err != nil {
+ return patchVPPTokenRenewResponse{Err: err}, nil
+ }
+
+ return patchVPPTokenRenewResponse{Token: tok}, nil
+}
+
+func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
+
+////////////////////////////////////////////////////
+// PATCH /api/_version_/fleet/vpp_tokens/%d/teams //
+////////////////////////////////////////////////////
+
+type patchVPPTokensTeamsRequest struct {
+ ID uint `url:"id"`
+ TeamIDs []uint `json:"teams"`
+}
+
+type patchVPPTokensTeamsResponse struct {
+ Token *fleet.VPPTokenDB `json:"token,omitempty"`
+ Err error `json:"error,omitempty"`
+}
+
+func (r patchVPPTokensTeamsResponse) error() error { return r.Err }
+
+func patchVPPTokensTeams(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
+ req := request.(*patchVPPTokensTeamsRequest)
+
+ tok, err := svc.UpdateVPPTokenTeams(ctx, req.ID, req.TeamIDs)
+ if err != nil {
+ return patchVPPTokensTeamsResponse{Err: err}, nil
+ }
+ return patchVPPTokensTeamsResponse{Token: tok}, nil
+}
+
+func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
+
+/////////////////////////////////////////
+// GET /api/_version_/fleet/vpp_tokens //
+/////////////////////////////////////////
+
+type getVPPTokensRequest struct{}
+
+type getVPPTokensResponse struct {
+ Tokens []*fleet.VPPTokenDB `json:"vpp_tokens"`
+ Err error `json:"error,omitempty"`
+}
+
+func (r getVPPTokensResponse) error() error { return r.Err }
+
+func getVPPTokens(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
+ tokens, err := svc.GetVPPTokens(ctx)
+ if err != nil {
+ return getVPPTokensResponse{Err: err}, nil
+ }
+
+ if tokens == nil {
+ tokens = []*fleet.VPPTokenDB{}
+ }
+
+ return getVPPTokensResponse{Tokens: tokens}, nil
+}
+
+func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
+
+///////////////////////////////////////////////
+// DELETE /api/_version_/fleet/vpp_tokens/%d //
+///////////////////////////////////////////////
+
+type deleteVPPTokenRequest struct {
+ ID uint `url:"id"`
+}
+
+type deleteVPPTokenResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r deleteVPPTokenResponse) error() error { return r.Err }
+
+func (r deleteVPPTokenResponse) Status() int { return http.StatusNoContent }
+
+func deleteVPPToken(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
+ req := request.(*deleteVPPTokenRequest)
+
+ err := svc.DeleteVPPToken(ctx, req.ID)
+ if err != nil {
+ return deleteVPPTokenResponse{Err: err}, nil
+ }
+
+ return deleteVPPTokenResponse{}, nil
+}
+
+func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID 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/vpp_test.go b/server/service/vpp_test.go
index 1821b9926f89..95b9c65ed0d9 100644
--- a/server/service/vpp_test.go
+++ b/server/service/vpp_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"time"
+ "github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
@@ -14,13 +15,24 @@ import (
)
func TestVPPAuth(t *testing.T) {
- t.Skip()
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license})
+ // use a custom implementation of checkAuthErr as the service call will fail
+ // with a different error for in case of authorization success and the
+ // package-wide checkAuthErr requires no error.
+ checkAuthErr := func(t *testing.T, shouldFail bool, err error) {
+ if shouldFail {
+ require.Error(t, err)
+ require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
+ } else if err != nil {
+ require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error())
+ }
+ }
+
testCases := []struct {
name string
user *fleet.User
@@ -64,14 +76,15 @@ func TestVPPAuth(t *testing.T) {
ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) {
return false, nil
}
-
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil
}
-
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
return &fleet.Team{ID: 1}, nil
}
+ ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) {
+ return &fleet.VPPTokenDB{ID: 1, OrgName: "org", Teams: []fleet.TeamTuple{{ID: 1}}}, nil
+ }
// Note: these calls always return an error because they're attempting to unmarshal a
// non-existent VPP token.
@@ -79,18 +92,14 @@ func TestVPPAuth(t *testing.T) {
if tt.teamID == nil {
require.Error(t, err)
} else {
- if tt.shouldFailRead {
- checkAuthErr(t, true, err)
- }
+ checkAuthErr(t, tt.shouldFailRead, err)
}
err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}})
if tt.teamID == nil {
require.Error(t, err)
} else {
- if tt.shouldFailWrite {
- checkAuthErr(t, true, err)
- }
+ checkAuthErr(t, tt.shouldFailWrite, err)
}
})
}
diff --git a/server/test/mdm.go b/server/test/mdm.go
index 0bcd17d258a0..df59cb2e6c9d 100644
--- a/server/test/mdm.go
+++ b/server/test/mdm.go
@@ -36,6 +36,14 @@ func CreateVPPTokenEncoded(expiration time.Time, orgName, location string) ([]by
if err != nil {
return nil, err
}
+ return []byte(dataToken.Token), nil
+}
+
+func CreateVPPTokenEncodedAfterMigration(expiration time.Time, orgName, location string) ([]byte, error) {
+ dataToken, err := CreateVPPTokenData(expiration, orgName, location)
+ if err != nil {
+ return nil, err
+ }
dataTokenJson, err := json.Marshal(dataToken)
if err != nil {
diff --git a/server/test/new_objects.go b/server/test/new_objects.go
index 099b7a0e579c..f8a2de578c24 100644
--- a/server/test/new_objects.go
+++ b/server/test/new_objects.go
@@ -210,6 +210,12 @@ func WithPlatform(s string) NewHostOption {
}
}
+func WithTeamID(teamID uint) NewHostOption {
+ return func(h *fleet.Host) {
+ h.TeamID = &teamID
+ }
+}
+
func NewHost(tb testing.TB, ds fleet.Datastore, name, ip, key, uuid string, now time.Time, options ...NewHostOption) *fleet.Host {
osqueryHostID, _ := server.GenerateRandomText(10)
h := &fleet.Host{
diff --git a/server/vulnerabilities/nvd/cve.go b/server/vulnerabilities/nvd/cve.go
index f7bc5fa49c40..7b321ef94788 100644
--- a/server/vulnerabilities/nvd/cve.go
+++ b/server/vulnerabilities/nvd/cve.go
@@ -588,6 +588,14 @@ func expandCPEAliases(cpeItem *wfn.Attributes) []*wfn.Attributes {
}
}
+ for _, cpeItem := range cpeItems {
+ if cpeItem.Vendor == "oracle" && cpeItem.Product == "virtualbox" {
+ cpeItem2 := *cpeItem
+ cpeItem2.Product = "vm_virtualbox"
+ cpeItems = append(cpeItems, &cpeItem2)
+ }
+ }
+
return cpeItems
}
diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go
index e1314833277e..691f3e321a7d 100644
--- a/server/vulnerabilities/nvd/cve_test.go
+++ b/server/vulnerabilities/nvd/cve_test.go
@@ -347,6 +347,14 @@ func TestTranslateCPEToCVE(t *testing.T) {
},
continuesToUpdate: true,
},
+ // Tests the expandCPEAliases rule for virtualbox on macOS
+ "cpe:2.3:a:oracle:virtualbox:7.0.6:*:*:*:*:macos:*:*": {
+ includedCVEs: []cve{
+ {ID: "CVE-2023-21989", resolvedInVersion: "7.0.8"},
+ {ID: "CVE-2024-21141", resolvedInVersion: "7.0.20"},
+ },
+ continuesToUpdate: true,
+ },
}
cveOSTests := []struct {
diff --git a/server/worker/db_migrations.go b/server/worker/db_migrations.go
index be5bc31762cf..88d0839d1985 100644
--- a/server/worker/db_migrations.go
+++ b/server/worker/db_migrations.go
@@ -2,7 +2,10 @@ package worker
import (
"context"
+ "encoding/base64"
"encoding/json"
+ "fmt"
+ "time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -70,7 +73,7 @@ func (m *DBMigration) migrateVPPToken(ctx context.Context) error {
return ctxerr.Wrap(ctx, err, "get VPP token to migrate")
}
- rawToken, didUpdate, err := tok.ExtractToken()
+ tokenData, didUpdate, err := extractVPPTokenFromMigration(tok)
if err != nil {
return ctxerr.Wrap(ctx, err, "extract VPP token metadata")
}
@@ -81,7 +84,47 @@ func (m *DBMigration) migrateVPPToken(ctx context.Context) error {
m.Log.Log("info", "VPP token metadata was not updated")
}
- tokenData := fleet.VPPTokenData{Token: rawToken, Location: tok.Location}
- _, err = m.Datastore.UpdateVPPToken(ctx, tok.ID, &tokenData)
- return ctxerr.Wrap(ctx, err, "update VPP token")
+ if _, err := m.Datastore.UpdateVPPToken(ctx, tok.ID, tokenData); err != nil {
+ return ctxerr.Wrap(ctx, err, "update VPP token")
+ }
+ // the migated token should target "All teams"
+ _, err = m.Datastore.UpdateVPPTokenTeams(ctx, tok.ID, []uint{})
+ return ctxerr.Wrap(ctx, err, "update VPP token teams")
+}
+
+func extractVPPTokenFromMigration(migratedToken *fleet.VPPTokenDB) (tokData *fleet.VPPTokenData, didUpdateMetadata bool, err error) {
+ var vppTokenData fleet.VPPTokenData
+ if err := json.Unmarshal([]byte(migratedToken.Token), &vppTokenData); err != nil {
+ return nil, false, fmt.Errorf("unmarshaling VPP token data: %w", err)
+ }
+
+ vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token)
+ if err != nil {
+ return nil, false, fmt.Errorf("decoding raw vpp token data: %w", err)
+ }
+
+ var vppTokenRaw fleet.VPPTokenRaw
+ if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil {
+ return nil, false, fmt.Errorf("unmarshaling raw vpp token data: %w", err)
+ }
+
+ exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate)
+ if err != nil {
+ return nil, false, fmt.Errorf("parsing vpp token expiration date: %w", err)
+ }
+
+ if vppTokenData.Location != migratedToken.Location {
+ migratedToken.Location = vppTokenData.Location
+ didUpdateMetadata = true
+ }
+ if vppTokenRaw.OrgName != migratedToken.OrgName {
+ migratedToken.OrgName = vppTokenRaw.OrgName
+ didUpdateMetadata = true
+ }
+ if !exp.Equal(migratedToken.RenewDate) {
+ migratedToken.RenewDate = exp.UTC()
+ didUpdateMetadata = true
+ }
+
+ return &vppTokenData, didUpdateMetadata, nil
}
diff --git a/server/worker/db_migrations_test.go b/server/worker/db_migrations_test.go
index 8faca12acd4c..1aeeb697e9e2 100644
--- a/server/worker/db_migrations_test.go
+++ b/server/worker/db_migrations_test.go
@@ -12,12 +12,11 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/log"
"github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDBMigrationsVPPToken(t *testing.T) {
- // FIXME
- t.Skip()
ctx := context.Background()
ds := mysql.CreateMySQLDS(t)
@@ -38,7 +37,7 @@ func TestDBMigrationsVPPToken(t *testing.T) {
// create the migrated token and enqueue the job
expDate := time.Date(2024, 8, 27, 0, 0, 0, 0, time.UTC)
- tok, err := test.CreateVPPTokenEncoded(expDate, "test-org", "test-loc")
+ tok, err := test.CreateVPPTokenEncodedAfterMigration(expDate, "test-org", "test-loc")
require.NoError(t, err)
encTok, err := mysql.EncryptWithPrivateKey(t, ds, tok)
require.NoError(t, err)
@@ -49,12 +48,10 @@ INSERT INTO vpp_tokens
organization_name,
location,
renew_at,
- token,
- team_id,
- null_team_type
+ token
)
VALUES
- ('', '', DATE('2000-01-01'), ?, NULL, 'allteams')
+ ('', '', DATE('2000-01-01'), ?)
`
const insJob = `
@@ -93,7 +90,9 @@ VALUES (?, ?, ?, '', ?, ?, ?)
// nothing more to run
jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
- require.Empty(t, jobs)
+ if !assert.Empty(t, jobs) {
+ t.Logf(">>> %#+v", jobs[0])
+ }
// token should've been updated
vppTok, err := ds.GetVPPTokenByLocation(ctx, "test-loc")
@@ -101,7 +100,7 @@ VALUES (?, ?, ?, '', ?, ?, ?)
require.Equal(t, "test-org", vppTok.OrgName)
require.Equal(t, "test-loc", vppTok.Location)
require.Equal(t, expDate, vppTok.RenewDate)
- require.Equal(t, string(tok), vppTok.Token)
+ require.Contains(t, string(tok), `"token":"`+vppTok.Token+`"`) // the DB-stored token is the "token" JSON field in the raw tok
require.NotNil(t, vppTok.Teams)
require.Len(t, vppTok.Teams, 0)
diff --git a/server/worker/macos_setup_assistant.go b/server/worker/macos_setup_assistant.go
index 59676b85595c..d47391713832 100644
--- a/server/worker/macos_setup_assistant.go
+++ b/server/worker/macos_setup_assistant.go
@@ -229,18 +229,20 @@ func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args maco
return ctxerr.Wrap(ctx, err, "get team")
}
- skipSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, args.HostSerialNumbers)
+ cooldownSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, args.HostSerialNumbers)
if err != nil {
return ctxerr.Wrap(ctx, err, "run hosts transferred")
}
- if !fromCooldown {
- // if not a retry, then we need to screen the serials for cooldown
- if len(skipSerials) > 0 {
- // NOTE: the `dep_cooldown` job of the `integrations` cron picks up the assignments
- // after the cooldown period is over
- level.Info(m.Log).Log("msg", "run hosts transferred: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials))
+ // if it's a retry, serials on cooldown need to be assigned as well.
+ if fromCooldown {
+ for k, v := range cooldownSerials {
+ assignSerials[k] = append(assignSerials[k], v...)
}
+ } else if len(cooldownSerials) > 0 {
+ // NOTE: the `dep_cooldown` job of the `integrations` cron picks up the assignments
+ // after the cooldown period is over
+ level.Info(m.Log).Log("msg", "run hosts transferred: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", cooldownSerials))
}
if len(assignSerials) == 0 {
@@ -389,17 +391,17 @@ func QueueMacosSetupAssistantJob(
}
func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
- serialsByTeamId, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx)
+ serialsByTeamID, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting cooldowns")
}
- if len(serialsByTeamId) == 0 {
+ if len(serialsByTeamID) == 0 {
level.Info(logger).Log("msg", "no cooldowns to process")
return nil
}
// queue job for each team so that macOS setup assistant worker can pick it up and process it
- for teamID, serials := range serialsByTeamId {
+ for teamID, serials := range serialsByTeamID {
if len(serials) == 0 {
logger.Log("msg", "no cooldowns", "team_id", teamID)
continue
diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go
index ffb449491168..f137b418bb42 100644
--- a/server/worker/macos_setup_assistant_test.go
+++ b/server/worker/macos_setup_assistant_test.go
@@ -24,8 +24,6 @@ import (
)
func TestMacosSetupAssistant(t *testing.T) {
- // FIXME
- t.Skip()
ctx := context.Background()
ds := mysql.CreateMySQLDS(t)
// call TruncateTables immediately as some DB migrations may create jobs
@@ -195,19 +193,6 @@ func TestMacosSetupAssistant(t *testing.T) {
require.False(t, modTime.Before(start))
}
}
- // the default token is not used by any team, only defined for no team (due
- // to it defaulting to no team)
- for _, tmID := range tmIDs {
- profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, "FIXME")
- if tmID == nil {
- require.NoError(t, err)
- require.Equal(t, defaultProfileName, profUUID, "tmID", getTeamID(tmID))
- require.False(t, modTime.Before(start))
- } else {
- require.Error(t, err)
- require.ErrorIs(t, err, sql.ErrNoRows)
- }
- }
require.Equal(t, map[string]string{
"serial-0": defaultProfileName,
"serial-1": defaultProfileName,
diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt
index 9166cec7a875..86388d153e44 100644
--- a/tools/cloner-check/generated_files/appconfig.txt
+++ b/tools/cloner-check/generated_files/appconfig.txt
@@ -97,7 +97,7 @@ github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration Domain string
github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration ApiKey map[string]string
github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM
github.com/fleetdm/fleet/v4/server/fleet/MDM DeprecatedAppleBMDefaultTeam string
-github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBussinessManager optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo]
+github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBusinessManager optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Value []fleet.MDMAppleABMAssignmentInfo
diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile
index 5d3369304bb8..bfe5fb62f1da 100644
--- a/tools/mdm/migration/mdmproxy/Dockerfile
+++ b/tools/mdm/migration/mdmproxy/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.22.5-alpine3.20@sha256:8c9183f715b0b4eca05b8b3dbf59766aaedb41ec07477b132ee2891ac0110a07
+FROM golang:1.22.6-alpine3.20@sha256:1a478681b671001b7f029f94b5016aed984a23ad99c707f6a0ab6563860ae2f3
ARG TAG
RUN apk update && apk add --no-cache git
RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build .
diff --git a/tools/mdm/migration/mdmproxy/mdmproxy.go b/tools/mdm/migration/mdmproxy/mdmproxy.go
index 723db4f19f71..c59bf4b425b5 100644
--- a/tools/mdm/migration/mdmproxy/mdmproxy.go
+++ b/tools/mdm/migration/mdmproxy/mdmproxy.go
@@ -84,14 +84,6 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) {
return
}
- if !strings.HasPrefix(r.URL.Path, "/mdm") {
- if m.logSkipped {
- log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String())
- }
- http.Error(w, "Forbidden", http.StatusForbidden)
- return
- }
-
// Send all micromdm repo requests to the existing server
if strings.HasPrefix(r.URL.Path, "/repo") {
log.Printf("%s %s -> Existing (Repo)", r.Method, r.URL.String())
@@ -100,6 +92,14 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) {
}
+ if !strings.HasPrefix(r.URL.Path, "/mdm") {
+ if m.logSkipped {
+ log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String())
+ }
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
// Read the body of the request
body, err := io.ReadAll(r.Body)
_ = r.Body.Close()
diff --git a/website/api/controllers/deliver-talk-to-us-form-submission.js b/website/api/controllers/deliver-talk-to-us-form-submission.js
index af26395dc151..aae36db70f5e 100644
--- a/website/api/controllers/deliver-talk-to-us-form-submission.js
+++ b/website/api/controllers/deliver-talk-to-us-form-submission.js
@@ -73,17 +73,28 @@ module.exports = {
if(_.includes(sails.config.custom.bannedEmailDomainsForWebsiteSubmissions, emailDomain.toLowerCase())){
throw 'invalidEmailDomain';
}
-
+ // Set a default psychological stage and change reason.
+ let psyStageAndChangeReason = {
+ psychologicalStage: '4 - Has use case',
+ psychologicalStageChangeReason: 'Website - Contact forms'
+ };
+ if(this.req.me){
+ // If this user is logged in, check their current psychological stage, and if it is higher than 4, we won't set a psystage.
+ // This way, if a user has a psytage >4, we won't regress their psystage because they submitted this form.
+ if(['4 - Has use case', '5 - Personally confident', '6 - Has team buy-in'].includes(this.req.me.psychologicalStage)) {
+ psyStageAndChangeReason = {};
+ }
+ }
if(numberOfHosts >= 700){
- sails.helpers.salesforce.updateOrCreateContactAndAccountAndCreateLead.with({
+ sails.helpers.salesforce.updateOrCreateContactAndAccount.with({
emailAddress: emailAddress,
firstName: firstName,
lastName: lastName,
organization: organization,
- numberOfHosts: numberOfHosts,
primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined,
contactSource: 'Website - Contact forms',
- leadDescription: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`,
+ description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`,
+ ...psyStageAndChangeReason// Only (potentially) set psystage and change reason for >700 hosts.
}).exec((err)=>{
if(err) {
sails.log.warn(`Background task failed: When a user submitted the "Talk to us" form, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err);
diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js
index c5e2ec892b06..fbeeda63aebb 100644
--- a/website/api/controllers/view-endpoint-ops.js
+++ b/website/api/controllers/view-endpoint-ops.js
@@ -22,13 +22,23 @@ module.exports = {
}
// Get testimonials for the component.
let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials);
+ // Default the pagePersonalization to the user's primaryBuyingSituation.
+ let pagePersonalization = this.req.session.primaryBuyingSituation;
+ // If a pageMode query parameter is set, update the pagePersonalization value.
+ // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session.
+ // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation.
+ if(this.req.param('pageMode') === 'it'){
+ pagePersonalization = 'eo-it';
+ } else if(this.req.param('pageMode') === 'security'){
+ pagePersonalization = 'eo-security';
+ }
// Specify an order for the testimonials on this page using the last names of quote authors
- let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Harrison Ravazzolo','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone'];
- if(['eo-it', 'mdm'].includes(this.req.session.primaryBuyingSituation)){
- testimonialOrderForThisPage = [ 'Harrison Ravazzolo', 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez'];
- } else if(['eo-security', 'vm'].includes(this.req.session.primaryBuyingSituation)){
+ let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone'];
+ if(['eo-it', 'mdm'].includes(pagePersonalization)){
+ testimonialOrderForThisPage = [ 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez'];
+ } else if(['eo-security', 'vm'].includes(pagePersonalization)){
testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar','Alvaro Gutierrez', 'Joe Pistone'];
}
// Filter the testimonials by product category and the filtered list we built above.
@@ -48,6 +58,7 @@ module.exports = {
// Respond with view.
return {
testimonialsForScrollableTweets,
+ pagePersonalization,
};
}
diff --git a/website/api/controllers/webhooks/receive-from-github.js b/website/api/controllers/webhooks/receive-from-github.js
index 3923a50a06f8..4dcbad029b2c 100644
--- a/website/api/controllers/webhooks/receive-from-github.js
+++ b/website/api/controllers/webhooks/receive-from-github.js
@@ -89,6 +89,8 @@ module.exports = {
'SFriendLee',
'ddribeiro',
'rebeccaui',
+ 'allenhouchins',
+ 'harrisonravazzolo',
];
let GREEN_LABEL_COLOR = 'C2E0C6';// Β« Used in multiple places below. (FUTURE: Use the "+" prefix for this instead of color. 2022-05-05)
diff --git a/website/api/controllers/webhooks/receive-usage-analytics.js b/website/api/controllers/webhooks/receive-usage-analytics.js
index 9094c11d79bc..94f7fec744de 100644
--- a/website/api/controllers/webhooks/receive-usage-analytics.js
+++ b/website/api/controllers/webhooks/receive-usage-analytics.js
@@ -39,6 +39,10 @@ module.exports = {
numHostSoftwareInstalledPaths: {type: 'number', defaultsTo: 0},
numSoftwareCPEs: {type: 'number', defaultsTo: 0},
numSoftwareCVEs: {type: 'number', defaultsTo: 0},
+ aiFeaturesDisabled: {type: 'boolean', defaultsTo: false },
+ maintenanceWindowsEnabled: {type: 'boolean', defaultsTo: false },
+ maintenanceWindowsConfigured: {type: 'boolean', defaultsTo: false },
+ numHostsFleetDesktopEnabled: {type: 'number', defaultsTo: 0 },
},
diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js
index 980ffae8c0b6..05e9b29c956f 100644
--- a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js
+++ b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js
@@ -30,6 +30,10 @@ module.exports = {
'6 - Has team buy-in'
]
},
+ psychologicalStageChangeReason: {
+ type: 'string',
+ example: 'Website - Organic start flow'
+ },
// For new leads.
leadDescription: {
type: 'string',
@@ -58,7 +62,7 @@ module.exports = {
- fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, contactSource, leadDescription, numberOfHosts}) {
+ fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, leadDescription, numberOfHosts}) {
if(sails.config.environment !== 'production') {
sails.log('Skipping Salesforce integration...');
return;
@@ -72,6 +76,7 @@ module.exports = {
linkedinUrl,
primaryBuyingSituation,
psychologicalStage,
+ psychologicalStageChangeReason,
contactSource,
description: leadDescription,
});
diff --git a/website/api/models/HistoricalUsageSnapshot.js b/website/api/models/HistoricalUsageSnapshot.js
index 5481d42e5330..1f14b9b291cb 100644
--- a/website/api/models/HistoricalUsageSnapshot.js
+++ b/website/api/models/HistoricalUsageSnapshot.js
@@ -43,6 +43,10 @@ module.exports = {
numHostSoftwareInstalledPaths: {required: true, type: 'number'},
numSoftwareCPEs: {required: true, type: 'number'},
numSoftwareCVEs: {required: true, type: 'number'},
+ aiFeaturesDisabled: {required: true, type: 'boolean'},
+ maintenanceWindowsEnabled: {required: true, type: 'boolean'},
+ maintenanceWindowsConfigured: {required: true, type: 'boolean'},
+ numHostsFleetDesktopEnabled: {required: true, type: 'number'},
// βββββ¦βββ βββββ¦ββββ
// ββ£ ββββ β©βββ£ βββββ
diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less
index 94e56945a871..6eefec24a939 100644
--- a/website/assets/styles/layout.less
+++ b/website/assets/styles/layout.less
@@ -444,8 +444,9 @@ html, body {
}
}
[purpose='gh-button'] {
- margin-left: 20px;
- margin-right: 20px;
+ padding: 0px 20px;
+ min-width: 140px;
+ width: 140px;
}
[purpose='header-dropdown'] {
box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.4);
diff --git a/website/scripts/get-bug-and-pr-report.js b/website/scripts/get-bug-and-pr-report.js
index 5894be6ecb41..06791bf10ab5 100644
--- a/website/scripts/get-bug-and-pr-report.js
+++ b/website/scripts/get-bug-and-pr-report.js
@@ -33,8 +33,10 @@ module.exports = {
let daysSinceReleasedBugsWereOpened = [];
let allBugsWithUnreleasedLabel = [];
let allBugsWithReleasedLabel = [];
+ let allBugs32DaysOrOlder = [];
let allBugsCreatedInPastWeek = [];
let allBugsClosedInPastWeek = [];
+ let allBugsReportedByCustomersInPastWeek = [];
let daysSincePullRequestsWereOpened = [];
let daysSinceContributorPullRequestsWereOpened = [];
let commitToMergeTimesInDays = [];
@@ -44,7 +46,7 @@ module.exports = {
let allNonPublicOpenPrs = [];
let nonPublicPrsClosedInThePastThreeWeeks = [];
- // Product group KPIS
+
// Endpoint operations
let allBugsCreatedInPastWeekEndpointOps = [];
@@ -103,8 +105,16 @@ module.exports = {
let timeOpenInMS = Math.abs(todaysDate - issueOpenedOn);
// Convert the miliseconds to days and add the value to the daysSinceBugsWereOpened array
let timeOpenInDays = timeOpenInMS / ONE_DAY_IN_MILLISECONDS;
+ if (timeOpenInDays >= 32) {
+ allBugs32DaysOrOlder.push(issue);
+ }
if (timeOpenInDays <= 7) {
+ // All bugs in past week
allBugsCreatedInPastWeek.push(issue);
+ // Customer-reported bugs
+ if (issue.labels.some(label => label.name.indexOf('customer-') >= 0)) {
+ allBugsReportedByCustomersInPastWeek.push(issue);
+ }
// Get Endpoint Ops KPIs
if (issue.labels.some(label => label.name === '#g-endpoint-ops')) {
allBugsCreatedInPastWeekEndpointOps.push(issue);
@@ -132,6 +142,7 @@ module.exports = {
}
}
}
+
daysSinceBugsWereOpened.push(timeOpenInDays);
// Send to released or unreleased bugs array
if (issue.labels.some(label => label.name === '~unreleased bug')) {
@@ -316,8 +327,8 @@ module.exports = {
//
async()=>{
- // Fetch confidential and classified PRs (current open, and recent closed)
- for (let repoName of ['classified', 'confidential']) {
+ // Fetch confidential PRs (current open, and recent closed)
+ for (let repoName of ['confidential']) {
// [?] https://docs.github.com/en/free-pro-team@latest/rest/pulls/pulls#list-pull-requests
let openPrs = await sails.helpers.http.get(`https://api.github.com/repos/fleetdm/${encodeURIComponent(repoName)}/pulls`, {
state: 'open',
@@ -380,25 +391,12 @@ module.exports = {
// NOTE: If order of the KPI sheets columns changes, the order values are pushed into this array needs to change, as well.
kpiResults.push(
averageDaysContributorPullRequestsAreOpenFor,
- daysSinceContributorPullRequestsWereOpened.length,
- averageDaysPullRequestsAreOpenFor,
- daysSincePullRequestsWereOpened.length,
+ allBugs32DaysOrOlder.length,
+ allBugsReportedByCustomersInPastWeek.length,
averageNumberOfDaysReleasedBugsAreOpenFor,
averageNumberOfDaysUnreleasedBugsAreOpenFor,
- allBugsClosedInPastWeek.length,
- averageNumberOfDaysBugsAreOpenFor,
allBugsCreatedInPastWeek.length,
- allBugsCreatedInPastWeekEndpointOps.length,
- allBugsCreatedInPastWeekEndpointOpsCustomerImpacting.length,
- allBugsCreatedInPastWeekEndpointOpsReleased.length,
- allBugsCreatedInPastWeekEndpointOpsUnreleased.length,
- allBugsCreatedInPastWeekMobileDeviceManagement.length,
- allBugsCreatedInPastWeekMobileDeviceManagementCustomerImpacting.length,
- allBugsCreatedInPastWeekMobileDeviceManagementReleased.length,
- allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length,
- daysSinceBugsWereOpened.length,
- allBugsWithReleasedLabel.length,
- allBugsWithUnreleasedLabel.length);
+ allBugsClosedInPastWeek.length,);
// Log the results
sails.log(`
@@ -407,17 +405,19 @@ module.exports = {
---------------------------
${kpiResults.join(',')}
- Note: Copy the values above, then in Google sheets paste them into a cell and select "Split text to columns" to paste the values into separate cells.
+ Note: Copy the values above, then paste into Google KPI sheet and select "Split text to columns" to split the values into separate columns.
Pull requests:
---------------------------
Average open time (no bots, no handbook, no ceo): ${averageDaysContributorPullRequestsAreOpenFor} days.
+
Number of open pull requests in the fleetdm/fleet Github repo (no bots, no handbook, no ceo): ${daysSinceContributorPullRequestsWereOpened.length}
Average open time (all PRs): ${averageDaysPullRequestsAreOpenFor} days.
+
Number of open pull requests in the fleetdm/fleet Github repo: ${daysSincePullRequestsWereOpened.length}
- Bugs (part 1):
+ Bugs:
---------------------------
Average open time (released bugs): ${averageNumberOfDaysReleasedBugsAreOpenFor} days.
@@ -429,6 +429,12 @@ module.exports = {
Number of issues with the "bug" label opened in the past week: ${allBugsCreatedInPastWeek.length}
+ Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length}
+
+ Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length}
+
+ Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length}
+
Endpoint Operations:
---------------------------
Number of issues with the "#g-endpoint-ops" and "bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOps.length}
@@ -449,17 +455,10 @@ module.exports = {
Number of issues with the "#g-mdm", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length}
- Bugs (part 2):
- ---------------------------
- Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length}
-
- Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length}
-
- Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length}
-
Pull requests requiring CEO review
---------------------------------------
Number of open ~ceo pull requests in the fleetdm Github org: ${ceoDependentOpenPrs.length}
+
Average open time (~ceo PRs): ${Math.round(ceoDependentPrOpenTime*100)/100} days.
`);
diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js
index 959c3625b532..af3cccceb66b 100644
--- a/website/scripts/send-aggregated-metrics-to-datadog.js
+++ b/website/scripts/send-aggregated-metrics-to-datadog.js
@@ -406,6 +406,69 @@ module.exports = {
}],
tags: [`enabled:false`],
});
+ // aiFeaturesDisabled
+ let numberOfInstancesWithAiFeaturesDisabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {aiFeaturesDisabled: true}).length;
+ let numberOfInstancesWithAiFeaturesEnabled = numberOfInstancesToReport - numberOfInstancesWithAiFeaturesDisabled;
+ metricsToReport.push({
+ metric: 'usage_statistics.ai_features',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithAiFeaturesEnabled
+ }],
+ tags: [`enabled:true`],
+ });
+ metricsToReport.push({
+ metric: 'usage_statistics.ai_features',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithAiFeaturesDisabled
+ }],
+ tags: [`enabled:false`],
+ });
+ // maintenanceWindowsEnabled
+ let numberOfInstancesWithMaintenanceWindowsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length;
+ let numberOfInstancesWithMaintenanceWindowsDisabled = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled;
+ metricsToReport.push({
+ metric: 'usage_statistics.maintenance_windows',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithMaintenanceWindowsEnabled
+ }],
+ tags: [`enabled:true`],
+ });
+ metricsToReport.push({
+ metric: 'usage_statistics.maintenance_windows',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithMaintenanceWindowsDisabled
+ }],
+ tags: [`enabled:false`],
+ });
+ // maintenanceWindowsConfigured
+ let numberOfInstancesWithMaintenanceWindowsConfigured = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length;
+ let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled;
+ metricsToReport.push({
+ metric: 'usage_statistics.maintenance_windows_configured',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithMaintenanceWindowsConfigured
+ }],
+ tags: [`configured:true`],
+ });
+ metricsToReport.push({
+ metric: 'usage_statistics.maintenance_windows_configured',
+ type: 3,
+ points: [{
+ timestamp: timestampForTheseMetrics,
+ value: numberOfInstancesWithoutMaintenanceWindowsConfigured
+ }],
+ tags: [`configured:false`],
+ });
// Create two metrics to track total number of hosts reported in the last week.
let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'premium'}), 'numHostsEnrolled'));
diff --git a/website/views/pages/contact.ejs b/website/views/pages/contact.ejs
index 52bb23bf6f1f..d9045a69e4d8 100644
--- a/website/views/pages/contact.ejs
+++ b/website/views/pages/contact.ejs
@@ -145,17 +145,17 @@
-
+
- When we look at vendors, we look for ones that are very receptive to feedback, where youβre just part of the family, I guess. Fleetβs really good at that.
+ Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.
-
+
-
Harrison Ravazzolo
-
Lead platform and identity engineer
+
Wes Whetstone
+
Staff CPE
diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs
index 18043c869fd8..22168e47a385 100644
--- a/website/views/pages/endpoint-ops.ejs
+++ b/website/views/pages/endpoint-ops.ejs
@@ -3,22 +3,22 @@
-
Endpoint operations <%= ['eo-security', 'vm'].includes(primaryBuyingSituation) ? 'for security' : ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'for IT' : '' %>
- <%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>
+ Endpoint operations <%= ['eo-security', 'vm'].includes(pagePersonalization) ? 'for security' : ['eo-it', 'mdm'].includes(pagePersonalization) ? 'for IT' : '' %>
+ <%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>
- <% if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) { %>
+ <% if(['eo-it', 'mdm'].includes(pagePersonalization)) { %>
Automate anything
Remotely run scripts and prompts to complete tasks on every kind of computer, including Linux.
Pulse check anything
Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.
Ship data to any platform
Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.
- <% } else if(['eo-security', 'vm'].includes(primaryBuyingSituation)) { %>
+ <% } else if(['eo-security', 'vm'].includes(pagePersonalization)) { %>
Osquery on easy mode
Build the agent in "read-only" mode or enable remote scripting to automatically mitigate misconfigurations and vulnerabilities.
Pulse check anything
@@ -41,7 +41,7 @@
- <% if (['eo-security'].includes(primaryBuyingSituation)) { %>
+ <% if (['eo-security'].includes(pagePersonalization)) { %>
@@ -57,7 +57,7 @@
- <% } else if (['vm'].includes(primaryBuyingSituation)) { %>
+ <% } else if (['vm'].includes(pagePersonalization)) { %>
@@ -91,15 +91,15 @@
<% } %>
- <%if(['eo-security'].includes(primaryBuyingSituation)) {%>
+ <%if(['eo-security'].includes(pagePersonalization)) {%>
Play video
- <%} else if(['vm'].includes(primaryBuyingSituation)){%>
+ <%} else if(['vm'].includes(pagePersonalization)){%>
Play video
- <%} else if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) {%>
+ <%} else if(['eo-it', 'mdm'].includes(pagePersonalization)) {%>
Play video
@@ -116,7 +116,7 @@
- <% if(!primaryBuyingSituation || ['mdm', 'eo-it'].includes(primaryBuyingSituation)){%>
+ <% if(!pagePersonalization || ['mdm', 'eo-it'].includes(pagePersonalization)){%>
Automate anything
@@ -225,7 +225,7 @@
Osquery on easy mode
-
Accelerate deployment and get more out of osquery. You donβt need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(primaryBuyingSituation) ? 'endpoints' : 'devices' %>.
+
Accelerate deployment and get more out of osquery. You donβt need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(pagePersonalization) ? 'endpoints' : 'devices' %>.
Remotely disable/enable agent features, choose plugins, and keep osquery up to date.
Import community queries from other security teams at top brands like Palantir and Fastly.
@@ -235,7 +235,7 @@
- <% if(!primaryBuyingSituation || ['vm', 'eo-security'].includes(primaryBuyingSituation)) {%>
+ <% if(!pagePersonalization || ['vm', 'eo-security'].includes(pagePersonalization)) {%>
Open security tooling
Consolidate your security tooling on top of open data standards like YAML, SQL, and JSON.
@@ -289,7 +289,7 @@
Who else uses Fleet?
- Empowering <%= ['mdm'].includes(primaryBuyingSituation) ? 'IT and corporate engineering' : ['eo-it'].includes(primaryBuyingSituation) ? 'IT and client platform' : ['eo-security'].includes(primaryBuyingSituation) ? 'security and platform' : ['vm'].includes(primaryBuyingSituation) ? 'security and IT' : 'IT and security' %> teams, globally
+ Empowering <%= ['mdm'].includes(pagePersonalization) ? 'IT and corporate engineering' : ['eo-it'].includes(pagePersonalization) ? 'IT and client platform' : ['eo-security'].includes(pagePersonalization) ? 'security and platform' : ['vm'].includes(pagePersonalization) ? 'security and IT' : 'IT and security' %> teams, globally
@@ -298,7 +298,7 @@
Endpoint operations
-
<%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>
+
<%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>
Start now
Talk to us
diff --git a/website/views/pages/entrance/signup.ejs b/website/views/pages/entrance/signup.ejs
index 437deb454ad4..40b71c7c1da4 100644
--- a/website/views/pages/entrance/signup.ejs
+++ b/website/views/pages/entrance/signup.ejs
@@ -80,15 +80,15 @@
- When we look at vendors, we look for ones that are very receptive to feedback, where youβre just part of the family, I guess. Fleetβs really good at that.
+ Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.
-
+
-
Harrison Ravazzolo
-
Lead platform and identity engineer
+
Wes Whetstone
+
Staff CPE
diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs
index b95a9d4930ba..d21fd43c3904 100644
--- a/website/views/pages/homepage.ejs
+++ b/website/views/pages/homepage.ejs
@@ -62,7 +62,7 @@
Osquery on easy mode
Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.
@@ -80,7 +80,7 @@
Ship data to any platform
Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.
@@ -108,7 +108,7 @@
<%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>
A <%= primaryBuyingSituation==='eo-security'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='eo-security'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.
-
Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%>
+
Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%>
@@ -175,7 +175,7 @@
<%= primaryBuyingSituation==='vm'? 'Instrument your endpoints' : 'Understand your computers'%>
A <%= primaryBuyingSituation==='vm'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='vm'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.
-
Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%>
+
Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%>