- End user experience
+ End user experience
After the end user continues past the Remote Management screen,
macOS Setup Assistant displays several screens by default.
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss
index 07f7e9b79782..ba465dd47bb3 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss
@@ -1,16 +1,16 @@
.setup-assistant-preview {
font-size: $x-small;
- h2 {
+ h3 {
margin: 0;
font-size: $small;
font-weight: normal;
}
&__preview-img {
- margin-top: $pad-xxlarge;
width: 100%;
display: block;
- margin: 40px auto 0;
+ margin: $pad-xxlarge auto 0;
+ border-radius: $border-radius-large;
}
}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx
new file mode 100644
index 000000000000..33bda27b9d45
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+
+import mockServer from "test/mock-server";
+import { createCustomRenderer } from "test/test-utils";
+import {
+ defaultSetupExperienceScriptHandler,
+ errorNoSetupExperienceScript,
+} from "test/handlers/setup-experience-handlers";
+
+import SetupExperienceScript from "./SetupExperienceScript";
+
+describe("SetupExperienceScript", () => {
+ it("should render the script uploader when no script has been uploaded", async () => {
+ mockServer.use(errorNoSetupExperienceScript);
+ const render = createCustomRenderer({ withBackendMock: true });
+
+ render( );
+
+ expect(await screen.findByRole("button", { name: "Upload" })).toBeVisible();
+ });
+
+ it("should render the uploaded script uploader when a script has been uploaded", async () => {
+ mockServer.use(defaultSetupExperienceScriptHandler);
+ const render = createCustomRenderer({ withBackendMock: true });
+
+ render( );
+
+ expect(
+ await screen.findByText("Script will run during setup:")
+ ).toBeVisible();
+ expect(await screen.findByText("Test Script.sh")).toBeVisible();
+ });
+});
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx
new file mode 100644
index 000000000000..46dbc4696ce8
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx
@@ -0,0 +1,116 @@
+import React, { useState } from "react";
+import { useQuery } from "react-query";
+import { AxiosError } from "axios";
+
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+import mdmAPI, {
+ IGetSetupExperienceScriptResponse,
+} from "services/entities/mdm";
+
+import SectionHeader from "components/SectionHeader";
+import DataError from "components/DataError";
+import Spinner from "components/Spinner";
+
+import CustomLink from "components/CustomLink";
+
+import SetupExperiencePreview from "./components/SetupExperienceScriptPreview";
+import SetupExperienceScriptUploader from "./components/SetupExperienceScriptUploader";
+import SetupExperienceScriptCard from "./components/SetupExperienceScriptCard";
+import DeleteSetupExperienceScriptModal from "./components/DeleteSetupExperienceScriptModal";
+
+const baseClass = "setup-experience-script";
+
+interface ISetupExperienceScriptProps {
+ currentTeamId: number;
+}
+
+const SetupExperienceScript = ({
+ currentTeamId,
+}: ISetupExperienceScriptProps) => {
+ const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
+
+ const {
+ data: script,
+ error: scriptError,
+ isLoading,
+ isError,
+ refetch: refetchScript,
+ remove: removeScriptFromCache,
+ } = useQuery(
+ ["setup-experience-script", currentTeamId],
+ () => mdmAPI.getSetupExperienceScript(currentTeamId),
+ { ...DEFAULT_USE_QUERY_OPTIONS, retry: false }
+ );
+
+ const onUpload = () => {
+ refetchScript();
+ };
+
+ const onDelete = () => {
+ removeScriptFromCache();
+ setShowDeleteScriptModal(false);
+ refetchScript();
+ };
+
+ const scriptUploaded = true;
+
+ const renderContent = () => {
+ if (isLoading) {
+ ;
+ }
+
+ if (isError && scriptError.status !== 404) {
+ return ;
+ }
+
+ return (
+
+
+
+ Upload a script to run on hosts that automatically enroll to Fleet.
+
+
+ {!scriptUploaded || !script ? (
+
+ ) : (
+ <>
+
+ Script will run during setup:
+
+
setShowDeleteScriptModal(true)}
+ />
+ >
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+
+ <>{renderContent()}>
+ {showDeleteScriptModal && script && (
+ setShowDeleteScriptModal(false)}
+ />
+ )}
+
+ );
+};
+
+export default SetupExperienceScript;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss
new file mode 100644
index 000000000000..46d6c04ede05
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss
@@ -0,0 +1,22 @@
+.setup-experience-script {
+ &__content {
+ max-width: $break-xxl;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: $pad-xxlarge;
+ }
+
+ &__description {
+ margin: 0;
+ }
+
+ &__learn-how-link {
+ margin-bottom: $pad-large;
+ }
+
+ &__run-message {
+ margin: 0 0 $pad-small;
+ font-weight: $bold;
+ }
+}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx
new file mode 100644
index 000000000000..3097a1992fd8
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx
@@ -0,0 +1,60 @@
+import React, { useContext } from "react";
+
+import mdmAPI from "services/entities/mdm";
+import { NotificationContext } from "context/notification";
+
+import Button from "components/buttons/Button";
+import Modal from "components/Modal";
+
+const baseClass = "delete-setup-experience-script-modal";
+
+interface IDeleteSetupExperienceScriptModalProps {
+ currentTeamId: number;
+ scriptName: string;
+ onExit: () => void;
+ onDeleted: () => void;
+}
+
+const DeleteSetupExperienceScriptModal = ({
+ currentTeamId,
+ scriptName,
+ onExit,
+ onDeleted,
+}: IDeleteSetupExperienceScriptModalProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+
+ const onDelete = async () => {
+ try {
+ await mdmAPI.deleteSetupExperienceScript(currentTeamId);
+ renderFlash("success", "Setup script successfully deleted!");
+ } catch (error) {
+ renderFlash(
+ "error",
+ "Couldn't delete the setup script. Please try again."
+ );
+ console.error(error);
+ }
+
+ onDeleted();
+ };
+
+ return (
+
+ <>
+
+ The script {scriptName} will still run on pending hosts.
+
+
+
+ Delete
+
+
+ Cancel
+
+
+ >
+
+ );
+};
+
+export default DeleteSetupExperienceScriptModal;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts
new file mode 100644
index 000000000000..f608071e62f5
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./DeleteSetupExperienceScriptModal";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx
new file mode 100644
index 000000000000..9c83a3c5379a
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx
@@ -0,0 +1,75 @@
+import React, { useContext } from "react";
+import FileSaver from "file-saver";
+
+import mdmAPI, {
+ IGetSetupExperienceScriptResponse,
+} from "services/entities/mdm";
+
+import { uploadedFromNow } from "utilities/date_format";
+
+import Button from "components/buttons/Button";
+import Card from "components/Card";
+import Graphic from "components/Graphic";
+import Icon from "components/Icon";
+import { NotificationContext } from "context/notification";
+import { API_NO_TEAM_ID } from "interfaces/team";
+
+const baseClass = "setup-experience-script-card";
+
+interface ISetupExperienceScriptCardProps {
+ script: IGetSetupExperienceScriptResponse;
+ onDelete: () => void;
+}
+
+const SetupExperienceScriptCard = ({
+ script,
+ onDelete,
+}: ISetupExperienceScriptCardProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+
+ const onDownload = async () => {
+ try {
+ const teamId = script.team_id ?? API_NO_TEAM_ID;
+ const data = await mdmAPI.downloadSetupExperienceScript(teamId);
+ const date = new Date();
+ const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${
+ script.name
+ }`;
+ const file = new global.window.File([data], filename);
+
+ FileSaver.saveAs(file);
+ } catch (e) {
+ renderFlash("error", "Couldn't download script. Please try again.");
+ }
+ };
+
+ return (
+
+
+
+ {script.name}
+
+ {uploadedFromNow(script.created_at)}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SetupExperienceScriptCard;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss
new file mode 100644
index 000000000000..902afafabb0b
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss
@@ -0,0 +1,32 @@
+.setup-experience-script-card {
+ display: flex;
+ gap: $pad-medium;
+ align-items: center;
+
+ // TODO: create reusable list item component and use instead of all these styles.
+ &__info {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__profile-name {
+ font-size: $x-small;
+ font-weight: $bold;
+ }
+
+ &__uploaded-at {
+ font-size: $xx-small;
+ }
+
+ &__actions {
+ display: flex;
+ gap: $pad-medium;
+ flex: 1;
+ justify-content: flex-end;
+ }
+
+ &__download-button,
+ &__delete-button {
+ padding: 11px; // TODO: use a padding value from existing variables. talk to design.
+ }
+}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts
new file mode 100644
index 000000000000..c7e125f47fa8
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupExperienceScriptCard";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx
new file mode 100644
index 000000000000..ea05cdd386f2
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+import Card from "components/Card";
+
+import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png";
+
+const baseClass = "setup-experience-script-preview";
+
+const SetupExperienceScriptPreview = () => {
+ return (
+
+ End user experience
+
+ After software is installed, the end user will see the script being run.
+ They will not be able to continue until the script runs.
+
+
+ If there are any errors, they will be able to continue and will be
+ instructed to contact their IT admin.
+
+
+
+ );
+};
+
+export default SetupExperienceScriptPreview;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss
new file mode 100644
index 000000000000..b2fc9958164f
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss
@@ -0,0 +1,13 @@
+.setup-experience-script-preview {
+ h3 {
+ margin: 0;
+ font-size: $small;
+ font-weight: normal;
+ }
+
+ &__preview-img {
+ margin-top: $pad-xxlarge;
+ width: 100%;
+ display: block;
+ }
+}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts
new file mode 100644
index 000000000000..8cf535c231f0
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupExperienceScriptPreview";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx
new file mode 100644
index 000000000000..91a6c9a28efc
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx
@@ -0,0 +1,63 @@
+import React, { useContext, useState } from "react";
+import classnames from "classnames";
+
+import mdmAPI from "services/entities/mdm";
+
+import { NotificationContext } from "context/notification";
+import FileUploader from "components/FileUploader";
+import { getErrorReason } from "interfaces/errors";
+
+const baseClass = "setup-experience-script-uploader";
+
+interface ISetupExperienceScriptUploaderProps {
+ currentTeamId: number;
+ onUpload: () => void;
+ className?: string;
+}
+
+const SetupExperienceScriptUploader = ({
+ currentTeamId,
+ onUpload,
+ className,
+}: ISetupExperienceScriptUploaderProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+ const [showLoading, setShowLoading] = useState(false);
+
+ const classNames = classnames(baseClass, className);
+
+ const onUploadFile = async (files: FileList | null) => {
+ setShowLoading(true);
+
+ if (!files || files.length === 0) {
+ setShowLoading(false);
+ return;
+ }
+
+ const file = files[0];
+
+ try {
+ await mdmAPI.uploadSetupExperienceScript(file, currentTeamId);
+ renderFlash("success", "Successfully uploaded!");
+ onUpload();
+ } catch (e) {
+ // TODO: what errors?
+ renderFlash("error", getErrorReason(e));
+ }
+
+ setShowLoading(false);
+ };
+
+ return (
+
+ );
+};
+
+export default SetupExperienceScriptUploader;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts
new file mode 100644
index 000000000000..e4bd667f020f
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupExperienceScriptUploader";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts
new file mode 100644
index 000000000000..143ecd15ab90
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupExperienceScript";
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
index fa75f1d5541d..3888638cad46 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
@@ -12,6 +12,9 @@ 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.";
+const DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG =
+ "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again.";
+
interface IDeleteSoftwareModalProps {
softwareId: number;
teamId: number;
@@ -36,6 +39,8 @@ const DeleteSoftwareModal = ({
const reason = getErrorReason(error);
if (reason.includes("Policy automation uses this software")) {
renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG);
+ } else if (reason.includes("This software is installed when")) {
+ renderFlash("error", DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG);
} else {
renderFlash("error", "Couldn't delete. Please try again.");
}
diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts
index 15c9ab99c8a5..d7c4f9c4dd8a 100644
--- a/frontend/router/paths.ts
+++ b/frontend/router/paths.ts
@@ -17,6 +17,8 @@ export default {
CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`,
CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`,
CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`,
+ CONTROLS_INSTALL_SOFTWARE: `${URL_PREFIX}/controls/setup-experience/install-software`,
+ CONTROLS_RUN_SCRIPT: `${URL_PREFIX}/controls/setup-experience/run-script`,
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
// Dashboard pages
diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts
index c851bc7b7f72..5b4d5b3fd49c 100644
--- a/frontend/services/entities/mdm.ts
+++ b/frontend/services/entities/mdm.ts
@@ -1,5 +1,3 @@
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
-import { createMockMdmProfile } from "__mocks__/mdmMock";
import {
DiskEncryptionStatus,
IHostMdmProfile,
@@ -11,6 +9,7 @@ import { API_NO_TEAM_ID } from "interfaces/team";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
+import { ISoftwareTitlesResponse } from "./software";
export interface IEulaMetadataResponse {
name: string;
@@ -82,6 +81,21 @@ export interface IGetMdmCommandResultsResponse {
results: IMdmCommandResult[];
}
+export interface IGetSetupExperienceScriptResponse {
+ id: number;
+ team_id: number | null; // The API return null for no team in this case.
+ name: string;
+ created_at: string;
+ updated_at: string;
+}
+
+interface IGetSetupExperienceSoftwareParams {
+ team_id: number;
+ per_page: number;
+}
+
+export type IGetSetupExperienceSoftwareResponse = ISoftwareTitlesResponse;
+
const mdmService = {
unenrollHostFromMdm: (hostId: number, timeout?: number) => {
const { HOST_MDM_UNENROLL } = endpoints;
@@ -276,6 +290,7 @@ const mdmService = {
return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body);
},
+
getSetupEnrollmentProfile: (teamId?: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
if (!teamId || teamId === API_NO_TEAM_ID) {
@@ -287,6 +302,7 @@ const mdmService = {
)}`;
return sendRequest("GET", path);
},
+
uploadSetupEnrollmentProfile: (file: File, teamId: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
@@ -313,6 +329,7 @@ const mdmService = {
});
});
},
+
deleteSetupEnrollmentProfile: (teamId: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
if (teamId === API_NO_TEAM_ID) {
@@ -324,6 +341,7 @@ const mdmService = {
)}`;
return sendRequest("DELETE", path);
},
+
getCommandResults: (
command_uuid: string
): Promise => {
@@ -341,6 +359,83 @@ const mdmService = {
"blob"
);
},
+
+ getSetupExperienceSoftware: (
+ params: IGetSetupExperienceSoftwareParams
+ ): Promise => {
+ const { MDM_SETUP_EXPERIENCE_SOFTWARE } = endpoints;
+
+ const path = `${MDM_SETUP_EXPERIENCE_SOFTWARE}?${buildQueryStringFromParams(
+ {
+ ...params,
+ }
+ )}`;
+
+ return sendRequest("GET", path);
+ },
+
+ updateSetupExperienceSoftware: (
+ teamId: number,
+ softwareTitlesIds: number[]
+ ) => {
+ const { MDM_SETUP_EXPERIENCE_SOFTWARE } = endpoints;
+
+ const path = `${MDM_SETUP_EXPERIENCE_SOFTWARE}?${buildQueryStringFromParams(
+ {
+ team_id: teamId,
+ }
+ )}`;
+
+ return sendRequest("PUT", path, {
+ team_id: teamId,
+ software_title_ids: softwareTitlesIds,
+ });
+ },
+
+ getSetupExperienceScript: (
+ teamId: number
+ ): Promise => {
+ const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
+
+ let path = MDM_SETUP_EXPERIENCE_SCRIPT;
+ if (teamId) {
+ path += `?${buildQueryStringFromParams({ team_id: teamId })}`;
+ }
+
+ return sendRequest("GET", path);
+ },
+
+ downloadSetupExperienceScript: (teamId: number) => {
+ const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
+
+ let path = MDM_SETUP_EXPERIENCE_SCRIPT;
+ path += `?${buildQueryStringFromParams({ team_id: teamId, alt: "media" })}`;
+
+ return sendRequest("GET", path);
+ },
+
+ uploadSetupExperienceScript: (file: File, teamId: number) => {
+ const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
+
+ const formData = new FormData();
+ formData.append("script", file);
+
+ if (teamId) {
+ formData.append("team_id", teamId.toString());
+ }
+
+ return sendRequest("POST", MDM_SETUP_EXPERIENCE_SCRIPT, formData);
+ },
+
+ deleteSetupExperienceScript: (teamId: number) => {
+ const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
+
+ const path = `${MDM_SETUP_EXPERIENCE_SCRIPT}?${buildQueryStringFromParams({
+ team_id: teamId,
+ })}`;
+
+ return sendRequest("DELETE", path);
+ },
};
export default mdmService;
diff --git a/frontend/services/index.ts b/frontend/services/index.ts
index a395d29b9191..8f850008a805 100644
--- a/frontend/services/index.ts
+++ b/frontend/services/index.ts
@@ -68,7 +68,7 @@ export const sendRequestWithProgress = async ({
};
export const sendRequest = async (
- method: "GET" | "POST" | "PATCH" | "DELETE" | "HEAD",
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD",
path: string,
data?: unknown,
responseType: AxiosResponseType = "json",
diff --git a/frontend/test/handlers/apple_mdm.ts b/frontend/test/handlers/apple_mdm.ts
index 5f8be1c3764d..648a8eb64644 100644
--- a/frontend/test/handlers/apple_mdm.ts
+++ b/frontend/test/handlers/apple_mdm.ts
@@ -3,7 +3,6 @@ import { rest } from "msw";
import { createMockVppInfo } from "__mocks__/appleMdm";
import { baseUrl } from "test/test-utils";
-// eslint-disable-next-line import/prefer-default-export
export const defaultVppInfoHandler = rest.get(
baseUrl("/vpp"),
(req, res, context) => {
diff --git a/frontend/test/handlers/setup-experience-handlers.ts b/frontend/test/handlers/setup-experience-handlers.ts
new file mode 100644
index 000000000000..23a1b8372469
--- /dev/null
+++ b/frontend/test/handlers/setup-experience-handlers.ts
@@ -0,0 +1,20 @@
+import { rest } from "msw";
+
+import { baseUrl } from "test/test-utils";
+import { createMockSetupExperienceScript } from "__mocks__/setupExperienceMock";
+
+const setupExperienceScriptUrl = baseUrl("/setup_experience/script");
+
+export const defaultSetupExperienceScriptHandler = rest.get(
+ setupExperienceScriptUrl,
+ (req, res, context) => {
+ return res(context.json(createMockSetupExperienceScript()));
+ }
+);
+
+export const errorNoSetupExperienceScript = rest.get(
+ setupExperienceScriptUrl,
+ (req, res, context) => {
+ return res(context.status(404));
+ }
+);
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 5833a486539c..bb6b3dea65b9 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -127,7 +127,6 @@ export default {
MDM_BOOTSTRAP_PACKAGE: `/${API_VERSION}/fleet/mdm/bootstrap`,
MDM_BOOTSTRAP_PACKAGE_SUMMARY: `/${API_VERSION}/fleet/mdm/bootstrap/summary`,
MDM_SETUP: `/${API_VERSION}/fleet/mdm/apple/setup`,
- MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`,
MDM_EULA: (token: string) => `/${API_VERSION}/fleet/mdm/setup/eula/${token}`,
MDM_EULA_UPLOAD: `/${API_VERSION}/fleet/mdm/setup/eula`,
MDM_EULA_METADATA: `/${API_VERSION}/fleet/mdm/setup/eula/metadata`,
@@ -139,6 +138,11 @@ export default {
ME: `/${API_VERSION}/fleet/me`,
+ // Setup experiece endpoints
+ MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`,
+ MDM_SETUP_EXPERIENCE_SOFTWARE: `/${API_VERSION}/fleet/setup_experience/software`,
+ MDM_SETUP_EXPERIENCE_SCRIPT: `/${API_VERSION}/fleet/setup_experience/script`,
+
// OS Version endpoints
OS_VERSIONS: `/${API_VERSION}/fleet/os_versions`,
OS_VERSION: (id: number) => `/${API_VERSION}/fleet/os_versions/${id}`,
diff --git a/orbit/changes/22383-swift-dialog b/orbit/changes/22383-swift-dialog
new file mode 100644
index 000000000000..b7644a3ef8f3
--- /dev/null
+++ b/orbit/changes/22383-swift-dialog
@@ -0,0 +1,2 @@
+- Adds a UI for the Fleet setup experience to show users the status of software installs and script
+executions during macOS Setup Assistant.
\ No newline at end of file
diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go
index 5f44a7c2ba81..3edd3c159857 100644
--- a/orbit/cmd/orbit/orbit.go
+++ b/orbit/cmd/orbit/orbit.go
@@ -30,6 +30,7 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/osservice"
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
+ setupexperience "github.com/fleetdm/fleet/v4/orbit/pkg/setup_experience"
"github.com/fleetdm/fleet/v4/orbit/pkg/table"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/fleetd_logs"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/orbit_info"
@@ -500,6 +501,7 @@ func main() {
}
targets := []string{"orbit", "osqueryd"}
+
if c.Bool("fleet-desktop") {
targets = append(targets, "desktop")
}
@@ -857,7 +859,7 @@ func main() {
)
scriptConfigReceiver, scriptsEnabledFn := update.ApplyRunScriptsConfigFetcherMiddleware(
- c.Bool("enable-scripts"), orbitClient,
+ c.Bool("enable-scripts"), orbitClient, c.String("root-dir"),
)
orbitClient.RegisterConfigReceiver(scriptConfigReceiver)
@@ -869,7 +871,10 @@ func main() {
orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{
UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval,
}))
+ setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient, c.String("root-dir"))
+ orbitClient.RegisterConfigReceiver(setupExperiencer)
orbitClient.RegisterConfigReceiver(update.ApplySwiftDialogDownloaderMiddleware(updateRunner))
+
case "windows":
orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMEnrollmentFetcherMiddleware(windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient))
orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMBitlockerFetcherMiddleware(windowsMDMBitlockerCommandFrequency, orbitClient))
@@ -1213,7 +1218,7 @@ func main() {
}
}
- softwareRunner := installer.NewRunner(orbitClient, r.ExtensionSocketPath(), scriptsEnabledFn)
+ softwareRunner := installer.NewRunner(orbitClient, r.ExtensionSocketPath(), scriptsEnabledFn, c.String("root-dir"))
orbitClient.RegisterConfigReceiver(softwareRunner)
if runtime.GOOS == "darwin" {
diff --git a/orbit/pkg/installer/installer.go b/orbit/pkg/installer/installer.go
index e0faf63c0fdf..d580ec46beec 100644
--- a/orbit/pkg/installer/installer.go
+++ b/orbit/pkg/installer/installer.go
@@ -14,6 +14,7 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/update"
"github.com/fleetdm/fleet/v4/pkg/file"
pkgscripts "github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -70,20 +71,30 @@ type Runner struct {
scriptsEnabled func() bool
osqueryConnectionMutex sync.Mutex
+
+ rootDirPath string
}
-func NewRunner(client Client, socketPath string, scriptsEnabled func() bool) *Runner {
+func NewRunner(client Client, socketPath string, scriptsEnabled func() bool, rootDirPath string) *Runner {
r := &Runner{
OrbitClient: client,
osquerySocketPath: socketPath,
scriptsEnabled: scriptsEnabled,
installerExecutionTimeout: pkgscripts.MaxHostSoftwareInstallExecutionTime,
+ rootDirPath: rootDirPath,
}
return r
}
func (r *Runner) Run(config *fleet.OrbitConfig) error {
+ if runtime.GOOS == "darwin" {
+ if config.Notifications.RunSetupExperience && !update.CanRun(r.rootDirPath, "swiftDialog", update.SwiftDialogMacOSTarget) {
+ log.Debug().Msg("exiting software installer config runner early during setup experience: swiftDialog is not installed")
+ return nil
+ }
+ }
+
connectOsqueryFn := r.connectOsquery
if connectOsqueryFn == nil {
connectOsqueryFn = connectOsquery
diff --git a/orbit/pkg/setup_experience/setup_experience.go b/orbit/pkg/setup_experience/setup_experience.go
new file mode 100644
index 000000000000..bc69884f4c76
--- /dev/null
+++ b/orbit/pkg/setup_experience/setup_experience.go
@@ -0,0 +1,295 @@
+package setupexperience
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/fleetdm/fleet/v4/orbit/pkg/swiftdialog"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/update"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/rs/zerolog/log"
+)
+
+const doneMessage = `### Setup is complete\n\nPlease contact your IT Administrator if there were any errors.`
+
+// Client is the minimal interface needed to communicate with the Fleet server.
+type Client interface {
+ GetSetupExperienceStatus() (*fleet.SetupExperienceStatusPayload, error)
+}
+
+// SetupExperiencer is the type that manages the Fleet setup experience flow during macOS Setup
+// Assistant. It uses swiftDialog as a UI for showing the status of software installations and
+// script execution that are configured to run before the user has full access to the device.
+// If the setup experience is supposed to run, it will launch a single swiftDialog instance and then
+// update that instance based on the results from the /orbit/setup_experience/status endpoint.
+type SetupExperiencer struct {
+ OrbitClient Client
+ closeChan chan struct{}
+ rootDirPath string
+ // Note: this object is not safe for concurrent use. Since the SetupExperiencer is a singleton,
+ // its Run method is called within a WaitGroup,
+ // and no other parts of Orbit need access to this field (or any other parts of the
+ // SetupExperiencer), it's OK to not protect this with a lock.
+ sd *swiftdialog.SwiftDialog
+ // Name of each step -> is that step done
+ steps map[string]bool
+ started bool
+}
+
+func NewSetupExperiencer(client Client, rootDirPath string) *SetupExperiencer {
+ return &SetupExperiencer{
+ OrbitClient: client,
+ closeChan: make(chan struct{}),
+ steps: make(map[string]bool),
+ rootDirPath: rootDirPath,
+ }
+}
+
+func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error {
+ if !oc.Notifications.RunSetupExperience {
+ log.Debug().Msg("skipping setup experience: notification flag is not set")
+ return nil
+ }
+
+ _, binaryPath, _ := update.LocalTargetPaths(
+ s.rootDirPath,
+ "swiftDialog",
+ update.SwiftDialogMacOSTarget,
+ )
+
+ if _, err := os.Stat(binaryPath); err != nil {
+ log.Debug().Msg("skipping setup experience: swiftDialog is not installed")
+ return nil
+ }
+
+ // Poll the status endpoint. This also releases the device if we're done.
+ payload, err := s.OrbitClient.GetSetupExperienceStatus()
+ if err != nil {
+ return err
+ }
+
+ // If swiftDialog isn't up yet, then launch it
+ orgLogo := payload.OrgLogoURL
+ if orgLogo == "" {
+ orgLogo = "https://fleetdm.com/images/permanent/fleet-mark-color-40x40@4x.png"
+ }
+
+ if err := s.startSwiftDialog(binaryPath, orgLogo); err != nil {
+ return err
+ }
+
+ // Defer this so that s.started is only false the first time this function runs.
+ defer func() { s.started = true }()
+
+ select {
+ case <-s.closeChan:
+ log.Debug().Str("receiver", "setup_experiencer").Msg("swiftDialog closed")
+ return nil
+ default:
+ // ok
+ }
+
+ // We're rendering the initial loading UI (shown while there are still profiles, bootstrap package,
+ // and account configuration to verify) right off the bat, so we can just no-op if any of those
+ // are not terminal
+
+ if payload.BootstrapPackage != nil {
+ if payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageFailed && payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageInstalled {
+ return nil
+ }
+ }
+
+ s.steps["bootstrap"] = true
+
+ if anyProfilePending(payload.ConfigurationProfiles) {
+ return nil
+ }
+
+ s.steps["config_profiles"] = true
+
+ if payload.AccountConfiguration != nil {
+ if payload.AccountConfiguration.Status != fleet.MDMAppleStatusAcknowledged &&
+ payload.AccountConfiguration.Status != fleet.MDMAppleStatusError &&
+ payload.AccountConfiguration.Status != fleet.MDMAppleStatusCommandFormatError {
+ return nil
+ }
+ }
+
+ s.steps["account_config"] = true
+
+ // Now render the UI for the software and script.
+ if len(payload.Software) > 0 || payload.Script != nil {
+ var stepsDone int
+ var prog uint
+ var steps []*fleet.SetupExperienceStatusResult
+ if len(payload.Software) > 0 {
+ steps = payload.Software
+ }
+
+ if payload.Script != nil {
+ steps = append(steps, payload.Script)
+ }
+
+ for _, step := range steps {
+ item := resultToListItem(step)
+ if _, ok := s.steps[step.Name]; ok {
+ err = s.sd.UpdateListItemByTitle(item.Title, item.StatusText, item.Status)
+ if err != nil {
+ log.Info().Err(err).Msg("updating list item in setup experience UI")
+ }
+ } else {
+ err = s.sd.AddListItem(item)
+ if err != nil {
+ log.Info().Err(err).Msg("adding list item in setup experience UI")
+ }
+ s.steps[step.Name] = false
+ }
+
+ if step.Status == fleet.SetupExperienceStatusFailure || step.Status == fleet.SetupExperienceStatusSuccess {
+ stepsDone++
+ s.steps[step.Name] = true
+ // The swiftDialog progress bar is out of 100
+ for range int(float32(1) / float32(len(steps)) * 100) {
+ prog++
+ }
+ }
+ }
+
+ if err = s.sd.UpdateProgress(prog); err != nil {
+ log.Info().Err(err).Msg("updating progress bar in setup experience UI")
+ }
+
+ if err := s.sd.ShowList(); err != nil {
+ log.Info().Err(err).Msg("showing progress bar in setup experience UI")
+ }
+
+ if err := s.sd.UpdateProgressText(fmt.Sprintf("%.0f%%", float32(stepsDone)/float32(len(steps))*100)); err != nil {
+ log.Info().Err(err).Msg("updating progress text in setup experience UI")
+ }
+
+ }
+
+ // If we get here, we can render the "done" UI.
+
+ if s.allStepsDone() {
+ if err := s.sd.SetMessage(doneMessage); err != nil {
+ log.Info().Err(err).Msg("setting message in setup experience UI")
+ }
+
+ if err := s.sd.CompleteProgress(); err != nil {
+ log.Info().Err(err).Msg("completing progress bar in setup experience UI")
+ }
+
+ if len(payload.Software) > 0 || payload.Script != nil {
+ // need to call this because SetMessage removes the list from the view for some reason :(
+ if err := s.sd.ShowList(); err != nil {
+ log.Info().Err(err).Msg("showing list in setup experience UI")
+ }
+ }
+
+ if err := s.sd.UpdateProgressText("100%"); err != nil {
+ log.Info().Err(err).Msg("updating progress text in setup experience UI")
+ }
+
+ if err := s.sd.EnableButton1(true); err != nil {
+ log.Info().Err(err).Msg("enabling close button in setup experience UI")
+ }
+ }
+
+ return nil
+}
+
+func (s *SetupExperiencer) allStepsDone() bool {
+ for _, done := range s.steps {
+ if !done {
+ return false
+ }
+ }
+
+ return true
+}
+
+func anyProfilePending(profiles []*fleet.SetupExperienceConfigurationProfileResult) bool {
+ for _, p := range profiles {
+ if p.Status == fleet.MDMDeliveryPending {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (s *SetupExperiencer) startSwiftDialog(binaryPath, orgLogo string) error {
+ if s.started {
+ return nil
+ }
+
+ created := make(chan struct{})
+ swiftDialog, err := swiftdialog.Create(context.Background(), binaryPath)
+ if err != nil {
+ return errors.New("creating swiftDialog instance: %w")
+ }
+ s.sd = swiftDialog
+ go func() {
+ initOpts := &swiftdialog.SwiftDialogOptions{
+ Title: "none",
+ Message: "### Setting up your Mac...\n\nYour Mac is being configured by your organization using Fleet. This process may take some time to complete. Please don't attempt to restart or shut down the computer unless prompted to do so.",
+ Icon: orgLogo,
+ MessageAlignment: swiftdialog.AlignmentCenter,
+ CentreIcon: true,
+ Height: "625",
+ Big: true,
+ ProgressText: "Configuring your device...",
+ Button1Text: "Close",
+ Button1Disabled: true,
+ }
+
+ if err := s.sd.Start(context.Background(), initOpts); err != nil {
+ log.Error().Err(err).Msg("starting swiftDialog instance")
+ }
+
+ if err = s.sd.ShowProgress(); err != nil {
+ log.Error().Err(err).Msg("setting initial setup experience progress")
+ }
+
+ if err := s.sd.SetIconSize(80); err != nil {
+ log.Error().Err(err).Msg("setting initial setup experience icon size")
+ }
+
+ log.Debug().Msg("swiftDialog process started")
+ created <- struct{}{}
+
+ if _, err = s.sd.Wait(); err != nil {
+ log.Error().Err(err).Msg("swiftdialog.Wait failed")
+ }
+
+ s.closeChan <- struct{}{}
+ }()
+ <-created
+ return nil
+}
+
+func resultToListItem(result *fleet.SetupExperienceStatusResult) swiftdialog.ListItem {
+ statusText := "Pending"
+ status := swiftdialog.StatusWait
+
+ switch result.Status {
+ case fleet.SetupExperienceStatusFailure:
+ status = swiftdialog.StatusFail
+ statusText = "Failed"
+ case fleet.SetupExperienceStatusSuccess:
+ status = swiftdialog.StatusSuccess
+ statusText = "Installed"
+ if result.IsForScript() {
+ statusText = "Ran"
+ }
+ }
+
+ return swiftdialog.ListItem{
+ Title: result.Name,
+ Status: status,
+ StatusText: statusText,
+ }
+}
diff --git a/orbit/pkg/swiftdialog/options.go b/orbit/pkg/swiftdialog/options.go
new file mode 100644
index 000000000000..9a7a416481ae
--- /dev/null
+++ b/orbit/pkg/swiftdialog/options.go
@@ -0,0 +1,313 @@
+package swiftdialog
+
+type SwiftDialogOptions struct {
+ // Set the Dialog title
+ Title string `json:"title,omitempty"`
+ // Text to use as subtitle when sending a system notification
+ Subtitle string `json:"subtitle,omitempty"`
+ // Set the dialog message
+ Message string `json:"message,omitempty"`
+ // Configure a pre-set window style
+ Style Style `json:"style,omitempty"`
+ // Set the message alignment
+ MessageAlignment Alignment `json:"messagealignment,omitempty"`
+ // Set the message position
+ MessagePosition Position `json:"messageposition,omitempty"`
+ // Enable help button with content
+ HelpMessage string `json:"helpmessage,omitempty"`
+ // Set the dialog icon, accepts file path, url, or builtin
+ // See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon
+ Icon string `json:"icon"`
+ // Set the dialog icon size
+ IconSize uint `json:"iconsize,omitempty"`
+ // Set the dialog icon transparancy
+ IconAlpha uint `json:"iconalpha,omitempty"`
+ // Set an image to display as an overlay to Icon, accepts file path or url
+ OverlayIcon string
+ // Enable banner image, accepts file path or url
+ BannerImage string `json:"bannerimage,omitempty"`
+ // Enable title within banner area
+ BannerTitle string `json:"bannertitle,omitempty"`
+ // Set text to display in banner area
+ BannerText string `json:"bannertext,omitempty"`
+ // Set the label for Button1
+ Button1Text string `json:"button1text,omitempty"`
+ // Set the Button1 action, accepts url
+ Button1Action string `json:"button1action,omitempty"`
+ // Displays Button2 with text
+ Button2Text string `json:"button2text,omitempty"`
+ // Custom Actions For Button 2 Is Not Implemented
+ Button2Action string `json:"button2action,omitempty"`
+ // Displays info button with text
+ InfoButtonText string `json:"infobuttontext,omitempty"`
+ // Set the info button action, accepts URL
+ InfoButtonAction string `json:"infobuttonaction,omitempty"`
+ // Configure how the button area is displayed
+ ButtonStyle ButtonStyle `json:"buttonstyle,omitempty"`
+ // Select Lists and Radio Buttons
+ SelectItems []SelectItems `json:"selectitems,omitempty"`
+ // Lets you modify the title text of the dialog
+ TitleFont string `json:"titlefont,omitempty"`
+ // Set the message font of the dialog
+ MessageFont string `json:"messagefont,omitempty"`
+ // Enable a textfield with the specified label
+ TextField []TextField `json:"textfield,omitempty"`
+ // Enable a checkbox with the specified label
+ Checkbox []Checkbox `json:"checkbox,omitempty"`
+ // Change the appearance of checkboxes
+ CheckboxStyle CheckboxStyle `json:"checkboxstyle,omitempty"`
+ // Enable countdown timer (in seconds)
+ Timer uint `json:"timer,omitempty"`
+ // Enable interactive progress bar
+ Progress uint `json:"progress,omitempty"`
+ // Enable the progress text
+ ProgressText string `json:"progresstext,omitempty"`
+ // Display an image
+ Image []Image `json:"image,omitempty"`
+ // Set dialog window width
+ Width uint `json:"width,omitempty"`
+ // Set dialog window height
+ Height string `json:"height,omitempty"`
+ // Set a dialog background image, accepts file path
+ Background string `json:"background,omitempty"`
+ // Set background image transparancy
+ BackgroundAlpha uint `json:"bgalpha,omitempty"`
+ // Set background image position
+ BackgroundPosition FullPosition `json:"bgposition,omitempty"`
+ // Set background image fill type
+ BackgroundFill BackgroundFill `json:"bgfill,omitempty"`
+ // Enable background image scaling
+ BackgroundScale BackgroundFill `json:"bgscale,omitempty"`
+ // Set dialog window position
+ Position FullPosition `json:"position,omitempty"`
+ // Set dialog window position offset
+ PositionOffset uint `json:"positionoffset,omitempty"`
+ // Display a video, accepts file path or url
+ Video string `json:"video,omitempty"`
+ // Display a caption underneath a video
+ VideoCaption string `json:"videocaption,omitempty"`
+ // Enable a list item with the specified label
+ ListItem []ListItem `json:"listitem,omitempty"`
+ // Set list style
+ ListStyle ListStyle `json:"liststyle,omitempty"`
+ // Display in place of info button
+ InfoText string `json:"infotext,omitempty"`
+ // Display in info box
+ InfoBox string `json:"infobox,omitempty"`
+ // Set dialog quit key
+ QuitKey string `json:"quitkey,omitempty"`
+ // Display a web page, accepts url
+ WebContent string `json:"webcontent,omitempty"`
+ // Use the specified authentication key to allow dialog to launch
+ Key string `json:"key,omitempty"`
+ // Generate a SHA256 value
+ Checksum string `json:"checksum,omitempty"`
+ // Open a file and display the contents as it is being written, accepts file path
+ DisplayLog string `json:"displaylog,omitempty"`
+ // Change the order in which some items are displayed, comma separated list
+ ViewOrder string `json:"vieworder,omitempty"`
+ // Set the preferred window appearance
+ Appearance Appearance `json:"appearance,omitempty"`
+ // Disable Button1
+ Button1Disabled bool `json:"button1disabled,omitempty"`
+ // Disable Button2
+ Button2Disabled bool `json:"button2disabled,omitempty"`
+ // Displays Button2
+ Button2 bool `json:"button2,omitempty"`
+ // Displays info button
+ InfoButton bool `json:"infobutton,omitempty"`
+ // Print version string
+ Version string `json:"version,omitempty"`
+ // Hides the icon from view
+ HideIcon bool `json:"hideicon,omitempty"`
+ // Set icon to be in the centre
+ CentreIcon bool `json:"centreicon,omitempty"`
+ // Hide countdown timer if enabled
+ HideTimerBar bool `json:"hidetimerbar,omitempty"`
+ // Enable video autoplay
+ Autoplay bool `json:"autoplay,omitempty"`
+ // Blur screen content behind dialog window
+ BlurScreen bool `json:"blurscreen,omitempty"`
+ // Send a system notification
+ Notification string `json:"notification,omitempty"`
+ // Enable dialog to be moveable
+ Moveable bool `json:"moveable,omitempty"`
+ // Enable dialog to be always positioned on top of other windows
+ OnTop bool `json:"ontop,omitempty"`
+ // Enable 25% decrease in default window size
+ Small bool `json:"small,omitempty"`
+ // Enable 25% increase in default window size
+ Big bool `json:"big,omitempty"`
+ // Enable full screen view
+ Fullscreen bool `json:"fullscreen,omitempty"`
+ // Quit when info button is selected
+ QuitonInfo bool `json:"quitoninfo,omitempty"`
+ // Enable mini mode
+ Mini bool `json:"mini,omitempty"`
+ // Enable presentation mode
+ Presentation bool `json:"presentation,omitempty"`
+ // Enables window buttons [close,min,max]
+ WindowButtons string `json:"windowbuttons,omitempty"`
+ // Enable the dialog window to be resizable
+ Resizable *bool `json:"resizable,omitempty"`
+ // Enable the dialog window to appear on all screens
+ ShowOnAllScreens *bool `json:"showonallscreens,omitempty"`
+ // Enable the dialog window to be shown at login
+ LoginWindow bool `json:"loginwindow,omitempty"`
+ // Hides the default behaviour of Return ↵ and Esc ⎋ keys
+ HideDefaultKeyboardAction bool `json:"hidedefaultkeyboardaction,omitempty"`
+}
+
+type Style string
+
+const (
+ StylePresentation Style = "presentation"
+ StyleMini Style = "mini"
+ StyleCentered Style = "centered"
+ StyleAlert Style = "alert"
+ StyleCaution Style = "caution"
+ StyleWarning Style = "warning"
+)
+
+type Alignment string
+
+const (
+ AlignmentLeft Alignment = "left"
+ AlignmentCenter Alignment = "center"
+ AlignmentRight Alignment = "right"
+)
+
+type Position string
+
+const (
+ PositionTop Position = "top"
+ PositionCenter Position = "center"
+ PositionBottom Position = "bottom"
+)
+
+type ButtonStyle string
+
+const (
+ ButtonStyleCenter ButtonStyle = "center"
+ ButtonStyleStack ButtonStyle = "stack"
+)
+
+type Checkbox struct {
+ Label string `json:"label"`
+ Checked bool `json:"checked"`
+ Disabled bool `json:"disabled"`
+ Icon string `json:"icon,omitempty"`
+ EnableButton1 bool `json:"enableButton1,omitempty"`
+}
+
+type Image struct {
+ // ImageName is a file path or url
+ ImageName string `json:"imagename"`
+ Caption string `json:"caption"`
+}
+
+type FullPosition string
+
+const (
+ FullPositionTopLeft FullPosition = "topleft"
+ FullPositionLeft FullPosition = "left"
+ FullPositionBottomLeft FullPosition = "bottomleft"
+ FullPositionTop FullPosition = "top"
+ FullPositionCenter FullPosition = "center"
+ FullPositionBottom FullPosition = "bottom"
+ FullPositionTopRight FullPosition = "topright"
+ FullPositionRight FullPosition = "right"
+ FullPositionBottomRight FullPosition = "bottomright"
+)
+
+type BackgroundFill string
+
+const (
+ BackgroundFillFill BackgroundFill = "fill"
+ BackgroundFillFit BackgroundFill = "fit"
+)
+
+type ListStyle string
+
+const (
+ ListStyleExpanded ListStyle = "expanded"
+ ListStyleCompact ListStyle = "compact"
+)
+
+type Appearance string
+
+const (
+ AppearanceDark Appearance = "dark"
+ AppearanceLight Appearance = "light"
+)
+
+type ListItem struct {
+ Title string `json:"title"`
+ Icon string `json:"icon,omitempty"`
+ Status Status `json:"status,omitempty"`
+ StatusText string `json:"statustext,omitempty"`
+}
+
+type Status string
+
+const (
+ StatusNone Status = ""
+ StatusWait Status = "wait"
+ StatusSuccess Status = "success"
+ StatusFail Status = "fail"
+ StatusError Status = "error"
+ StatusPending Status = "pending"
+ StatusProgress Status = "progress"
+)
+
+type TextField struct {
+ Title string `json:"title"`
+ Confirm bool `json:"confirm,omitempty"`
+ Editor bool `json:"editor,omitempty"`
+ FileSelect bool `json:"fileselect,omitempty"`
+ FileType string `json:"filetype,omitempty"`
+ Name string `json:"name,omitempty"`
+ Prompt string `json:"prompt,omitempty"`
+ Regex string `json:"regex,omitempty"`
+ RegexError string `json:"regexerror,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Secure bool `json:"secure,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+type CheckboxStyle struct {
+ Style string `json:"style"`
+ Size CheckboxStyleSize `json:"size"`
+}
+
+type CheckboxStyleStyle string
+
+const (
+ CheckboxDefault CheckboxStyleStyle = "default"
+ CheckboxCheckbox CheckboxStyleStyle = "checkbox"
+ CheckboxSwitch CheckboxStyleStyle = "switch"
+)
+
+type CheckboxStyleSize string
+
+const (
+ CheckboxMini CheckboxStyleSize = "mini"
+ CheckboxSmall CheckboxStyleSize = "small"
+ CheckboxRegular CheckboxStyleSize = "regular"
+ CheckboxLarge CheckboxStyleSize = "large"
+)
+
+type SelectItems struct {
+ Title string `json:"title"`
+ Values []string `json:"values"`
+ Default string `json:"default,omitempty"`
+ Style SelectItemsStyle `json:"style,omitempty"`
+ Required bool `json:"required,omitempty"`
+}
+
+type SelectItemsStyle string
+
+const (
+ SelectItemsStyleDropdown SelectItemsStyle = ""
+ SelectItemsStyleRadio SelectItemsStyle = "radio"
+)
diff --git a/orbit/pkg/swiftdialog/run.go b/orbit/pkg/swiftdialog/run.go
new file mode 100644
index 000000000000..29b89230f39b
--- /dev/null
+++ b/orbit/pkg/swiftdialog/run.go
@@ -0,0 +1,495 @@
+package swiftdialog
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// SwiftDialog really wants the command file to be mode 666 for some reason
+// https://github.com/swiftDialog/swiftDialog/wiki/Gotchas
+var CommandFilePerms = fs.FileMode(0o666)
+
+var (
+ ErrKilled = errors.New("process killed")
+ ErrWindowClosed = errors.New("window closed")
+)
+
+type SwiftDialog struct {
+ cancel context.CancelCauseFunc
+ cmd *exec.Cmd
+ commandFile *os.File
+ context context.Context
+ output *bytes.Buffer
+ exitCode ExitCode
+ exitErr error
+ done chan struct{}
+ binPath string
+}
+
+type SwiftDialogExit struct {
+ ExitCode ExitCode
+ Output map[string]any
+}
+
+type ExitCode int
+
+const (
+ ExitButton1 ExitCode = 0
+ ExitButton2 ExitCode = 2
+ ExitInfoButton ExitCode = 3
+ ExitTimer ExitCode = 4
+ ExitQuitCommand ExitCode = 5
+ ExitQuitKey ExitCode = 10
+ ExitKeyAuthFailed ExitCode = 30
+ ExitImageResourceNotFound ExitCode = 201
+ ExitFileNotFound ExitCode = 202
+)
+
+func Create(ctx context.Context, swiftDialogBin string) (*SwiftDialog, error) {
+ commandFile, err := os.CreateTemp("", "swiftDialogCommand")
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithCancelCause(ctx)
+
+ if err := commandFile.Chmod(CommandFilePerms); err != nil {
+ commandFile.Close()
+ os.Remove(commandFile.Name())
+ cancel(errors.New("could not create command file"))
+ return nil, err
+ }
+
+ sd := &SwiftDialog{
+ cancel: cancel,
+ commandFile: commandFile,
+ context: ctx,
+ done: make(chan struct{}),
+ binPath: swiftDialogBin,
+ }
+
+ return sd, nil
+}
+
+func (s *SwiftDialog) Start(ctx context.Context, opts *SwiftDialogOptions) error {
+ jsonBytes, err := json.Marshal(opts)
+ if err != nil {
+ return err
+ }
+
+ cmd := exec.CommandContext( //nolint:gosec
+ ctx,
+ s.binPath,
+ "--jsonstring", string(jsonBytes),
+ "--commandfile", s.commandFile.Name(),
+ "--json",
+ )
+
+ s.cmd = cmd
+
+ outBuf := &bytes.Buffer{}
+ cmd.Stdout = outBuf
+
+ s.output = outBuf
+
+ err = cmd.Start()
+ if err != nil {
+ s.cancel(errors.New("could not start swiftDialog"))
+ return err
+ }
+
+ go func() {
+ if err := cmd.Wait(); err != nil {
+ errExit := &exec.ExitError{}
+ if errors.As(err, &errExit) && strings.Contains(errExit.Error(), "exit status") {
+ s.exitCode = ExitCode(errExit.ExitCode())
+ } else {
+ s.exitErr = fmt.Errorf("waiting for swiftDialog: %w", err)
+ }
+ }
+ close(s.done)
+ s.cancel(ErrWindowClosed)
+ }()
+
+ // This sleep makes sure that SD is fully up and running and has access to the command file.
+ // We've found that if we start sending commands to the command file without this sleep, the
+ // commands may be lost.
+ time.Sleep(500 * time.Millisecond)
+
+ return nil
+}
+
+func (s *SwiftDialog) finished() {
+ <-s.done
+}
+
+func (s *SwiftDialog) Kill() error {
+ s.cancel(ErrKilled)
+ s.finished()
+ if err := s.cleanup(); err != nil {
+ return fmt.Errorf("Close cleaning up after swiftDialog: %w", err)
+ }
+
+ return nil
+}
+
+func (s *SwiftDialog) cleanup() error {
+ s.cancel(nil)
+ cmdFileName := s.commandFile.Name()
+ err := s.commandFile.Close()
+ if err != nil {
+ return fmt.Errorf("closing swiftDialog command file: %w", err)
+ }
+ err = os.Remove(cmdFileName)
+ if err != nil {
+ return fmt.Errorf("removing swiftDialog command file: %w", err)
+ }
+
+ return nil
+}
+
+func (s *SwiftDialog) Wait() (*SwiftDialogExit, error) {
+ s.finished()
+
+ parsed := map[string]any{}
+ if s.output.Len() != 0 {
+ if err := json.Unmarshal(s.output.Bytes(), &parsed); err != nil {
+ return nil, fmt.Errorf("parsing swiftDialog output: %w", err)
+ }
+ }
+
+ if err := s.cleanup(); err != nil {
+ return nil, fmt.Errorf("Wait cleaning up after swiftDialog: %w", err)
+ }
+
+ return &SwiftDialogExit{
+ ExitCode: s.exitCode,
+ Output: parsed,
+ }, s.exitErr
+}
+
+func (s *SwiftDialog) sendCommand(command, arg string) error {
+ if err := s.context.Err(); err != nil {
+ return fmt.Errorf("could not send command: %w", context.Cause(s.context))
+ }
+
+ fullCommand := fmt.Sprintf("%s: %s", command, arg)
+
+ return s.writeCommand(fullCommand)
+}
+
+func (s *SwiftDialog) sendMultiCommand(commands ...string) error {
+ multiCommands := strings.Join(commands, "\n")
+ return s.writeCommand(multiCommands)
+}
+
+func (s *SwiftDialog) writeCommand(fullCommand string) error {
+ // For some reason swiftDialog needs us to open and close the file
+ // to detect a new command, just writing to the file doesn't cause
+ // a change
+
+ commandFile, err := os.OpenFile(s.commandFile.Name(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, CommandFilePerms)
+ if err != nil {
+ return fmt.Errorf("opening command file for writing: %w", err)
+ }
+
+ _, err = fmt.Fprintf(commandFile, "%s\n", fullCommand)
+ if err != nil {
+ return fmt.Errorf("writing command to file: %w", err)
+ }
+
+ err = commandFile.Close()
+ if err != nil {
+ return fmt.Errorf("closing command file: %w", err)
+ }
+
+ return nil
+}
+
+///////////
+// Title //
+///////////
+
+// Updates the dialog title
+func (s *SwiftDialog) UpdateTitle(title string) error {
+ return s.sendCommand("title", title)
+}
+
+// Hides the title area
+func (s *SwiftDialog) HideTitle() error {
+ return s.sendCommand("title", "none")
+}
+
+/////////////
+// Message //
+/////////////
+
+// Set the dialog messsage
+func (s *SwiftDialog) SetMessage(text string) error {
+ return s.sendCommand("message", sanitize(text))
+}
+
+// Append to the dialog message
+func (s *SwiftDialog) AppendMessage(text string) error {
+ return s.sendCommand("message", fmt.Sprintf("+ %s", sanitize(text)))
+}
+
+// SetMessageKeepListItems sets the message to the given string while preserving the current list items.
+func (s *SwiftDialog) SetMessageKeepListItems(message string) error {
+ return s.sendMultiCommand(fmt.Sprintf("message: %s", sanitize(message)), "list: show")
+}
+
+///////////
+// Image //
+///////////
+
+// Displays the selected image
+func (s *SwiftDialog) Image(pathOrUrl string) error {
+ return s.sendCommand("image", pathOrUrl)
+}
+
+// Displays the specified text underneath any displayed image
+func (s *SwiftDialog) SetImageCaption(caption string) error {
+ return s.sendCommand("imagecaption", caption)
+}
+
+//////////////
+// Progress //
+//////////////
+
+// When Dialog is initiated with the Progress option, this will update the progress value
+func (s *SwiftDialog) UpdateProgress(progress uint) error {
+ return s.sendCommand("progress", fmt.Sprintf("%d", progress))
+}
+
+// Increments the progress by one
+func (s *SwiftDialog) IncrementProgress() error {
+ return s.sendCommand("progress", "increment")
+}
+
+// Resets the progress bar to 0
+func (s *SwiftDialog) ResetProgress() error {
+ return s.sendCommand("progress", "reset")
+}
+
+// Maxes out the progress bar
+func (s *SwiftDialog) CompleteProgress() error {
+ return s.sendCommand("progress", "complete")
+}
+
+// Hide the progress bar
+func (s *SwiftDialog) HideProgress() error {
+ return s.sendCommand("progress", "hide")
+}
+
+// Show the progress bar
+func (s *SwiftDialog) ShowProgress() error {
+ return s.sendCommand("progress", "show")
+}
+
+// Will update the label associated with the progress bar
+func (s *SwiftDialog) UpdateProgressText(text string) error {
+ return s.sendCommand("progresstext", text)
+}
+
+///////////
+// Lists //
+///////////
+
+// Create a list
+func (s *SwiftDialog) SetList(items []string) error {
+ return s.sendCommand("list", strings.Join(items, ","))
+}
+
+// Clears the list and removes it from display
+func (s *SwiftDialog) ClearList() error {
+ return s.sendCommand("list", "clear")
+}
+
+// Add a new item to the end of the current list
+func (s *SwiftDialog) AddListItem(item ListItem) error {
+ arg := fmt.Sprintf("add, title: %s", item.Title)
+ if item.Status != "" {
+ arg = fmt.Sprintf("%s, status: %s", arg, item.Status)
+ }
+ if item.StatusText != "" {
+ arg = fmt.Sprintf("%s, statustext: %s", arg, item.StatusText)
+ }
+ return s.sendCommand("listitem", arg)
+}
+
+// Delete an item by name
+func (s *SwiftDialog) DeleteListItemByTitle(title string) error {
+ return s.sendCommand("listitem", fmt.Sprintf("delete, title: %s", title))
+}
+
+// Delete an item by index number (starting at 0)
+func (s *SwiftDialog) DeleteListItemByIndex(index uint) error {
+ return s.sendCommand("listitem", fmt.Sprintf("delete, index: %d", index))
+}
+
+// Update a list item by name
+func (s *SwiftDialog) UpdateListItemByTitle(title, statusText string, status Status, progressPercent ...uint) error {
+ argStatus := string(status)
+ if len(progressPercent) == 1 && status == StatusProgress {
+ argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0])
+ }
+ arg := fmt.Sprintf("title: %s, status: %s, statustext: %s", title, argStatus, statusText)
+ return s.sendCommand("listitem", arg)
+}
+
+// Update a list item by index number (starting at 0)
+func (s *SwiftDialog) UpdateListItemByIndex(index uint, statusText string, status Status, progressPercent ...uint) error {
+ argStatus := string(status)
+ if len(progressPercent) == 1 && status == StatusProgress {
+ argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0])
+ }
+ arg := fmt.Sprintf("index: %d, status: %s, statustext: %s", index, argStatus, statusText)
+ return s.sendCommand("listitem", arg)
+}
+
+// ShowList forces the list to render.
+func (s *SwiftDialog) ShowList() error {
+ return s.sendCommand("list", "show")
+}
+
+/////////////
+// Buttons //
+/////////////
+
+// Enable or disable button 1
+func (s *SwiftDialog) EnableButton1(enable bool) error {
+ arg := "disable"
+ if enable {
+ arg = "enable"
+ }
+ return s.sendCommand("button1", arg)
+}
+
+// Enable or disable button 2
+func (s *SwiftDialog) EnableButton2(enable bool) error {
+ arg := "disable"
+ if enable {
+ arg = "enable"
+ }
+ return s.sendCommand("button2", arg)
+}
+
+// Changes the button 1 label
+func (s *SwiftDialog) SetButton1Text(text string) error {
+ return s.sendCommand("button1text", text)
+}
+
+// Changes the button 2 label
+func (s *SwiftDialog) SetButton2Text(text string) error {
+ return s.sendCommand("button2text", text)
+}
+
+// Changes the info button label
+func (s *SwiftDialog) SetInfoButtonText(text string) error {
+ return s.sendCommand("infobuttontext", text)
+}
+
+//////////////
+// Info box //
+//////////////
+
+// Update the content in the info box
+func (s *SwiftDialog) SetInfoBoxText(text string) error {
+ return s.sendCommand("infobox", sanitize(text))
+}
+
+// Append to the conteit in the info box
+func (s *SwiftDialog) AppendInfoBoxText(text string) error {
+ return s.sendCommand("infobox", fmt.Sprintf("+ %s", sanitize(text)))
+}
+
+//////////
+// Icon //
+//////////
+
+// Changes the displayed icon
+// See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon
+func (s *SwiftDialog) SetIconLocation(location string) error {
+ return s.sendCommand("icon", location)
+}
+
+// Moves the icon being shown
+func (s *SwiftDialog) SetIconAlignment(alignment Alignment) error {
+ return s.sendCommand("icon", string(alignment))
+}
+
+// Hide the icon
+func (s *SwiftDialog) HideIcon() error {
+ return s.sendCommand("icon", "hide")
+}
+
+// Changes the size of the displayed icon
+func (s *SwiftDialog) SetIconSize(size uint) error {
+ return s.sendCommand("icon", fmt.Sprintf("size: %d", size))
+}
+
+////////////
+// Window //
+////////////
+
+// Changes the width of the window maintaining the current position
+func (s *SwiftDialog) SetWindowWidth(width uint) error {
+ return s.sendCommand("width", fmt.Sprintf("%d", width))
+}
+
+// Changes the height of the window maintaining the current position
+func (s *SwiftDialog) SetWindowHeight(width uint) error {
+ return s.sendCommand("height", fmt.Sprintf("%d", width))
+}
+
+// Changes the window position
+func (s *SwiftDialog) SetWindowPosition(position FullPosition) error {
+ return s.sendCommand("position", string(position))
+}
+
+// Display content from the specified URL
+func (s *SwiftDialog) SetWebContent(url string) error {
+ return s.sendCommand("webcontent", url)
+}
+
+// Hide web content
+func (s *SwiftDialog) HideWebContent() error {
+ return s.sendCommand("webcontent", "none")
+}
+
+// Display a video from the specified path or URL
+func (s *SwiftDialog) SetVideo(location string) error {
+ return s.sendCommand("video", location)
+}
+
+// Enables or disables the blur window layer
+func (s *SwiftDialog) BlurScreen(enable bool) error {
+ blur := "disable"
+ if enable {
+ blur = "enable"
+ }
+ return s.sendCommand("blurscreen", blur)
+}
+
+// Activates the dialog window and brings it to the forground
+func (s *SwiftDialog) Activate() error {
+ return s.sendCommand("activate", "")
+}
+
+// Quits dialog with exit code 5 (ExitQuitCommand)
+func (s *SwiftDialog) Quit() error {
+ return s.sendCommand("quit", "")
+}
+
+func sanitize(text string) string {
+ return strings.ReplaceAll(text, "\n", "\\n")
+}
diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go
index fa5b6b993c6f..58d31cd0772e 100644
--- a/orbit/pkg/update/notifications.go
+++ b/orbit/pkg/update/notifications.go
@@ -293,15 +293,18 @@ type runScriptsConfigReceiver struct {
// ensures only one script execution runs at a time
mu sync.Mutex
+
+ rootDirPath string
}
func ApplyRunScriptsConfigFetcherMiddleware(
- scriptsEnabled bool, scriptsClient scripts.Client,
+ scriptsEnabled bool, scriptsClient scripts.Client, rootDirPath string,
) (fleet.OrbitConfigReceiver, func() bool) {
scriptsFetcher := &runScriptsConfigReceiver{
ScriptsExecutionEnabled: scriptsEnabled,
ScriptsClient: scriptsClient,
dynamicScriptsEnabledCheckInterval: 5 * time.Minute,
+ rootDirPath: rootDirPath,
}
// start the dynamic check for scripts enabled if required
scriptsFetcher.runDynamicScriptsEnabledCheck()
@@ -352,6 +355,13 @@ func (h *runScriptsConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
timeout = time.Duration(cfg.ScriptExeTimeout) * time.Second
}
+ if runtime.GOOS == "darwin" {
+ if cfg.Notifications.RunSetupExperience == true && !CanRun(h.rootDirPath, "swiftDialog", SwiftDialogMacOSTarget) {
+ log.Debug().Msg("exiting scripts config runner early during setup experience: swiftDialog is not installed")
+ return nil
+ }
+ }
+
if len(cfg.Notifications.PendingScriptExecutionIDs) > 0 {
if h.mu.TryLock() {
log.Debug().Msgf("received request to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
diff --git a/orbit/pkg/update/swift_dialog.go b/orbit/pkg/update/swift_dialog.go
index bdbfde5e3ee3..306ab0872f28 100644
--- a/orbit/pkg/update/swift_dialog.go
+++ b/orbit/pkg/update/swift_dialog.go
@@ -36,8 +36,8 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error {
// TODO: we probably want to ensure that swiftDialog is always installed if we're going to be
// using it offline.
- if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile {
- log.Debug().Msg("got false needs migration and false renew enrollment")
+ if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile && !cfg.Notifications.RunSetupExperience {
+ log.Debug().Msg("skipping swiftDialog update")
return nil
}
@@ -56,6 +56,15 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error {
s.UpdateRunner.updater.RemoveTargetInfo("swiftDialog")
return err
}
+
+ if cfg.Notifications.RunSetupExperience {
+ // Then update immediately, since we need to get swiftDialog quickly to show the setup
+ // experience
+ _, err := s.UpdateRunner.UpdateAction()
+ if err != nil {
+ return err
+ }
+ }
}
return nil
diff --git a/orbit/pkg/update/update.go b/orbit/pkg/update/update.go
index 56e4a79b7f4e..1b47a213f075 100644
--- a/orbit/pkg/update/update.go
+++ b/orbit/pkg/update/update.go
@@ -671,3 +671,17 @@ func (u *Updater) initializeDirectories() error {
return nil
}
+
+func CanRun(rootDirPath, targetName string, targetInfo TargetInfo) bool {
+ _, binaryPath, _ := LocalTargetPaths(
+ rootDirPath,
+ targetName,
+ targetInfo,
+ )
+
+ if _, err := os.Stat(binaryPath); err != nil {
+ return false
+ }
+
+ return true
+}
diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go
index b455b4eddd19..46314d0354a1 100644
--- a/pkg/mdm/mdmtest/apple.go
+++ b/pkg/mdm/mdmtest/apple.go
@@ -210,7 +210,7 @@ func (c *TestAppleMDMClient) Enroll() error {
if err := c.Authenticate(); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
- if err := c.TokenUpdate(); err != nil {
+ if err := c.TokenUpdate(true); err != nil {
return fmt.Errorf("token update: %w", err)
}
return nil
@@ -599,7 +599,7 @@ func (c *TestAppleMDMClient) Authenticate() error {
}
// TokenUpdate sends the TokenUpdate message to the MDM server (Check In protocol).
-func (c *TestAppleMDMClient) TokenUpdate() error {
+func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error {
payload := map[string]any{
"MessageType": "TokenUpdate",
"UDID": c.UUID,
@@ -609,6 +609,9 @@ func (c *TestAppleMDMClient) TokenUpdate() error {
"PushMagic": "pushmagic" + c.SerialNumber,
"Token": []byte("token" + c.SerialNumber),
}
+ if awaitingConfiguration {
+ payload["AwaitingConfiguration"] = true
+ }
_, err := c.request("application/x-apple-aspen-mdm-checkin", payload)
return err
}
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index f7aefa97d192..7c4618921998 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -757,9 +757,16 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
for _, item := range software.Packages {
var softwarePackageSpec fleet.SoftwarePackageSpec
if item.Path != nil {
- fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
+ softwarePackageSpec.ReferencedYamlPath = resolveApplyRelativePath(baseDir, *item.Path)
+ fileBytes, err := os.ReadFile(softwarePackageSpec.ReferencedYamlPath)
if err != nil {
- multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err))
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to read software package file %s: %v", *item.Path, err))
+ continue
+ }
+ // Replace $var and ${var} with env values.
+ fileBytes, err = ExpandEnvBytes(fileBytes)
+ if err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("failed to expand environmet in file %s: %v", *item.Path, err))
continue
}
if err := yaml.Unmarshal(fileBytes, &softwarePackageSpec); err != nil {
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index e5968c54211f..c2305dfe97ba 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -548,10 +548,12 @@ var hostRefs = []string{
// the host.uuid is not always named the same, so the map key is the table name
// and the map value is the column name to match to the host.uuid.
var additionalHostRefsByUUID = map[string]string{
- "host_mdm_apple_profiles": "host_uuid",
- "host_mdm_apple_bootstrap_packages": "host_uuid",
- "host_mdm_windows_profiles": "host_uuid",
- "host_mdm_apple_declarations": "host_uuid",
+ "host_mdm_apple_profiles": "host_uuid",
+ "host_mdm_apple_bootstrap_packages": "host_uuid",
+ "host_mdm_windows_profiles": "host_uuid",
+ "host_mdm_apple_declarations": "host_uuid",
+ "host_mdm_apple_awaiting_configuration": "host_uuid",
+ "setup_experience_status_results": "host_uuid",
}
// additionalHostRefsSoftDelete are tables that reference a host but for which
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index 24a52ef4c8e3..d87a880552a0 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -6769,6 +6769,18 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
_, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil)
require.NoError(t, err)
+ // Add an awaiting configuration entry
+ err = ds.SetHostAwaitingConfiguration(ctx, host.UUID, false)
+ require.NoError(t, err)
+
+ // Add a setup experience status result
+ err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "test.sh", ScriptContents: "echo foo"})
+ require.NoError(t, err)
+
+ added, err := ds.EnqueueSetupExperienceItems(ctx, host.UUID, 0)
+ require.NoError(t, err)
+ require.True(t, added)
+
// Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs {
var ok bool
@@ -9703,5 +9715,4 @@ func testGetHostEmails(t *testing.T, ds *Datastore) {
emails, err = ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingMDMIdpAccounts)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"foo@example.com", "bar@example.com"}, emails)
-
}
diff --git a/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go b/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go
new file mode 100644
index 000000000000..b17cff74f354
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go
@@ -0,0 +1,28 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241025111236, Down_20241025111236)
+}
+
+func Up_20241025111236(tx *sql.Tx) error {
+ _, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN install_during_setup BOOL NOT NULL DEFAULT false`)
+ if err != nil {
+ return fmt.Errorf("failed to add install_during_setup to software_installers: %w", err)
+ }
+
+ _, err = tx.Exec(`ALTER TABLE vpp_apps_teams ADD COLUMN install_during_setup BOOL NOT NULL DEFAULT false`)
+ if err != nil {
+ return fmt.Errorf("failed to add install_during_setup to vpp_apps_teams: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20241025111236(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go b/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go
new file mode 100644
index 000000000000..ad5f6e8125ae
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go
@@ -0,0 +1,101 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241025112748, Down_20241025112748)
+}
+
+func Up_20241025112748(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+CREATE TABLE setup_experience_scripts (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ team_id INT UNSIGNED DEFAULT NULL,
+ global_or_team_id INT UNSIGNED NOT NULL DEFAULT '0',
+ name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ script_content_id INT UNSIGNED DEFAULT NULL,
+
+ PRIMARY KEY (id),
+
+ UNIQUE KEY idx_setup_experience_scripts_global_or_team_id (global_or_team_id),
+
+ KEY idx_script_content_id (script_content_id),
+
+ CONSTRAINT fk_setup_experience_scripts_ibfk_1 FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT fk_setup_experience_scripts_ibfk_2 FOREIGN KEY (script_content_id) REFERENCES script_contents (id) ON DELETE CASCADE
+);
+
+`)
+ if err != nil {
+ return fmt.Errorf("failed to create setup_experience_scripts table: %w", err)
+ }
+
+ _, err = tx.Exec(`ALTER TABLE host_script_results ADD setup_experience_script_id INT UNSIGNED DEFAULT NULL`)
+ if err != nil {
+ return fmt.Errorf("failed to add setup_experience_scripts_id key to host_script_results: %w", err)
+ }
+
+ _, err = tx.Exec(`
+ALTER TABLE host_script_results
+ ADD CONSTRAINT fk_host_script_results_setup_experience_id
+ FOREIGN KEY (setup_experience_script_id)
+ REFERENCES setup_experience_scripts (id) ON DELETE SET NULL`)
+ if err != nil {
+ return fmt.Errorf("failed to add foreign key constraint for host_script_resutls setup_experience column: %w", err)
+ }
+
+ _, err = tx.Exec(`
+CREATE TABLE setup_experience_status_results (
+ id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ status ENUM('pending', 'running', 'success', 'failure') NOT NULL,
+
+ -- Software installer reference
+ software_installer_id INT(10) UNSIGNED,
+ -- Software installs reference
+ host_software_installs_execution_id VARCHAR(255),
+
+ -- VPP app reference
+ vpp_app_team_id INT(10) UNSIGNED,
+ -- VPP app install reference
+ nano_command_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci,
+
+ -- Setup script reference
+ setup_experience_script_id INT(10) UNSIGNED,
+ -- Script execution reference
+ script_execution_id VARCHAR(255) COLLATE utf8mb4_unicode_ci,
+ error VARCHAR(255) COLLATE utf8mb4_unicode_ci,
+
+
+ PRIMARY KEY (id),
+
+ KEY idx_setup_experience_scripts_host_uuid (host_uuid),
+ KEY idx_setup_experience_scripts_hsi_id (host_software_installs_execution_id),
+ KEY idx_setup_experience_scripts_nano_command_uuid (nano_command_uuid),
+ KEY idx_setup_experience_scripts_script_execution_id (script_execution_id),
+
+ CONSTRAINT fk_setup_experience_status_results_si_id FOREIGN KEY (software_installer_id) REFERENCES software_installers(id) ON DELETE CASCADE,
+ CONSTRAINT fk_setup_experience_status_results_va_id FOREIGN KEY (vpp_app_team_id) REFERENCES vpp_apps_teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_setup_experience_status_results_ses_id FOREIGN KEY (setup_experience_script_id) REFERENCES setup_experience_scripts(id) ON DELETE CASCADE
+)
+`)
+ // Service layer state machine like SetupExperienceNestStep()?
+ // Called from each of the three endpoints (software install, vpp
+ // mdm, scripts) involved in the setup when an eligible installer
+ // writes its results
+ if err != nil {
+ return fmt.Errorf("failed to create setup_experience_status_results table: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20241025112748(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go b/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go
new file mode 100644
index 000000000000..433b8fffa79e
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go
@@ -0,0 +1,27 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241025141855, Down_20241025141855)
+}
+
+func Up_20241025141855(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+CREATE TABLE host_mdm_apple_awaiting_configuration (
+ host_uuid VARCHAR(255) NOT NULL PRIMARY KEY,
+ awaiting_configuration TINYINT(1) NOT NULL DEFAULT FALSE
+)`)
+ if err != nil {
+ return fmt.Errorf("creating host_mdm_apple_awaiting_configuration table: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20241025141855(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index db3384cc8fec..3fa9bfa65040 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -65,7 +65,7 @@ CREATE TABLE `app_config_json` (
UNIQUE KEY `id` (`id`)
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
+INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `calendar_events` (
@@ -394,6 +394,14 @@ CREATE TABLE `host_mdm_actions` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `host_mdm_apple_awaiting_configuration` (
+ `host_uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `awaiting_configuration` tinyint(1) NOT NULL DEFAULT '0',
+ PRIMARY KEY (`host_uuid`)
+) 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 */;
CREATE TABLE `host_mdm_apple_bootstrap_packages` (
`host_uuid` varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL,
`command_uuid` varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL,
@@ -536,6 +544,7 @@ CREATE TABLE `host_script_results` (
`host_deleted_at` timestamp NULL DEFAULT NULL,
`timeout` int DEFAULT NULL,
`policy_id` int unsigned DEFAULT NULL,
+ `setup_experience_script_id` int unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`),
KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`),
@@ -544,7 +553,9 @@ CREATE TABLE `host_script_results` (
KEY `fk_host_script_results_user_id` (`user_id`),
KEY `script_content_id` (`script_content_id`),
KEY `fk_script_result_policy_id` (`policy_id`),
+ KEY `fk_host_script_results_setup_experience_id` (`setup_experience_script_id`),
CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL,
+ CONSTRAINT `fk_host_script_results_setup_experience_id` FOREIGN KEY (`setup_experience_script_id`) REFERENCES `setup_experience_scripts` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_host_script_results_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `host_script_results_ibfk_1` FOREIGN KEY (`script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE CASCADE,
CONSTRAINT `host_script_results_ibfk_2` FOREIGN KEY (`policy_id`) REFERENCES `policies` (`id`) ON DELETE SET NULL
@@ -1088,9 +1099,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=324 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=327 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'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,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,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,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` (
@@ -1652,6 +1663,51 @@ CREATE TABLE `sessions` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `setup_experience_scripts` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `team_id` int unsigned DEFAULT NULL,
+ `global_or_team_id` int unsigned NOT NULL DEFAULT '0',
+ `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `script_content_id` int unsigned DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_setup_experience_scripts_global_or_team_id` (`global_or_team_id`),
+ KEY `idx_script_content_id` (`script_content_id`),
+ KEY `fk_setup_experience_scripts_ibfk_1` (`team_id`),
+ CONSTRAINT `fk_setup_experience_scripts_ibfk_1` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `fk_setup_experience_scripts_ibfk_2` FOREIGN KEY (`script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE CASCADE
+) 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 */;
+CREATE TABLE `setup_experience_status_results` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `host_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `status` enum('pending','running','success','failure') COLLATE utf8mb4_unicode_ci NOT NULL,
+ `software_installer_id` int unsigned DEFAULT NULL,
+ `host_software_installs_execution_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+ `vpp_app_team_id` int unsigned DEFAULT NULL,
+ `nano_command_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+ `setup_experience_script_id` int unsigned DEFAULT NULL,
+ `script_execution_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+ `error` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_setup_experience_scripts_host_uuid` (`host_uuid`),
+ KEY `idx_setup_experience_scripts_hsi_id` (`host_software_installs_execution_id`),
+ KEY `idx_setup_experience_scripts_nano_command_uuid` (`nano_command_uuid`),
+ KEY `idx_setup_experience_scripts_script_execution_id` (`script_execution_id`),
+ KEY `fk_setup_experience_status_results_si_id` (`software_installer_id`),
+ KEY `fk_setup_experience_status_results_va_id` (`vpp_app_team_id`),
+ KEY `fk_setup_experience_status_results_ses_id` (`setup_experience_script_id`),
+ CONSTRAINT `fk_setup_experience_status_results_ses_id` FOREIGN KEY (`setup_experience_script_id`) REFERENCES `setup_experience_scripts` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_setup_experience_status_results_si_id` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_setup_experience_status_results_va_id` FOREIGN KEY (`vpp_app_team_id`) REFERENCES `vpp_apps_teams` (`id`) ON DELETE CASCADE
+) 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 */;
CREATE TABLE `software` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
@@ -1742,6 +1798,7 @@ CREATE TABLE `software_installers` (
`uninstall_script_content_id` int unsigned NOT NULL,
`updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`fleet_library_app_id` int unsigned DEFAULT NULL,
+ `install_during_setup` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`),
KEY `fk_software_installers_title` (`title_id`),
@@ -1873,6 +1930,7 @@ CREATE TABLE `vpp_apps_teams` (
`platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`self_service` tinyint(1) NOT NULL DEFAULT '0',
`vpp_token_id` int unsigned NOT NULL,
+ `install_during_setup` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`,`platform`),
KEY `team_id` (`team_id`),
diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go
index 68ddf4d12861..9e2a94304602 100644
--- a/server/datastore/mysql/scripts.go
+++ b/server/datastore/mysql/scripts.go
@@ -1217,7 +1217,10 @@ WHERE
SELECT 1 FROM fleet_library_apps fla
WHERE script_contents.id IN (fla.install_script_content_id, fla.uninstall_script_content_id)
)
- `
+ AND NOT EXISTS (
+ SELECT 1 FROM setup_experience_scripts WHERE script_content_id = script_contents.id
+ )
+`
_, err := ds.writer(ctx).ExecContext(ctx, deleteStmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "cleaning up unused script contents")
diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go
new file mode 100644
index 000000000000..328cbb12aec0
--- /dev/null
+++ b/server/datastore/mysql/setup_experience.go
@@ -0,0 +1,579 @@
+package mysql
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/jmoiron/sqlx"
+)
+
+func (ds *Datastore) EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) {
+ stmtClearSetupStatus := `
+DELETE FROM setup_experience_status_results
+WHERE host_uuid = ?`
+
+ stmtSoftwareInstallers := `
+INSERT INTO setup_experience_status_results (
+ host_uuid,
+ name,
+ status,
+ software_installer_id
+) SELECT
+ ?,
+ st.name,
+ 'pending',
+ si.id
+FROM software_installers si
+INNER JOIN software_titles st
+ ON si.title_id = st.id
+WHERE install_during_setup = true
+AND global_or_team_id = ?`
+
+ stmtVPPApps := `
+INSERT INTO setup_experience_status_results (
+ host_uuid,
+ name,
+ status,
+ vpp_app_team_id
+) SELECT
+ ?,
+ st.name,
+ 'pending',
+ vat.id
+FROM vpp_apps va
+INNER JOIN vpp_apps_teams vat
+ ON vat.adam_id = va.adam_id
+ AND vat.platform = va.platform
+INNER JOIN software_titles st
+ ON va.title_id = st.id
+WHERE vat.install_during_setup = true
+AND vat.global_or_team_id = ?`
+
+ stmtSetupScripts := `
+INSERT INTO setup_experience_status_results (
+ host_uuid,
+ name,
+ status,
+ setup_experience_script_id
+) SELECT
+ ?,
+ name,
+ 'pending',
+ id
+FROM setup_experience_scripts
+WHERE global_or_team_id = ?`
+
+ var totalInsertions uint
+ if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ // Clean out old statuses for the host
+ if _, err := tx.ExecContext(ctx, stmtClearSetupStatus, hostUUID); err != nil {
+ return ctxerr.Wrap(ctx, err, "removing stale setup experience entries")
+ }
+
+ // Software installers
+ res, err := tx.ExecContext(ctx, stmtSoftwareInstallers, hostUUID, teamID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "inserting setup experience software installers")
+ }
+ inserts, err := res.RowsAffected()
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "retrieving number of inserted software installers")
+ }
+ totalInsertions += uint(inserts) // nolint: gosec
+
+ // VPP apps
+ res, err = tx.ExecContext(ctx, stmtVPPApps, hostUUID, teamID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "inserting setup experience vpp apps")
+ }
+ inserts, err = res.RowsAffected()
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "retrieving number of inserted vpp apps")
+ }
+ totalInsertions += uint(inserts) // nolint: gosec
+
+ // Scripts
+ res, err = tx.ExecContext(ctx, stmtSetupScripts, hostUUID, teamID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "inserting setup experience scripts")
+ }
+ inserts, err = res.RowsAffected()
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "retrieving number of inserted setup experience scripts")
+ }
+ totalInsertions += uint(inserts) // nolint: gosec
+
+ if err := setHostAwaitingConfiguration(ctx, tx, hostUUID, true); err != nil {
+ return ctxerr.Wrap(ctx, err, "setting host awaiting configuration to true")
+ }
+
+ return nil
+ }); err != nil {
+ return false, ctxerr.Wrap(ctx, err, "enqueue setup experience")
+ }
+
+ return totalInsertions > 0, nil
+}
+
+func (ds *Datastore) SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error {
+ titleIDQuestionMarks := strings.Join(slices.Repeat([]string{"?"}, len(titleIDs)), ",")
+
+ stmtSelectInstallersIDs := fmt.Sprintf(`
+SELECT
+ st.id AS title_id,
+ si.id,
+ st.name,
+ si.platform
+FROM
+ software_titles st
+LEFT JOIN
+ software_installers si
+ ON st.id = si.title_id
+WHERE
+ si.global_or_team_id = ?
+AND
+ st.id IN (%s)
+`, titleIDQuestionMarks)
+
+ stmtSelectVPPAppsTeamsID := fmt.Sprintf(`
+SELECT
+ st.id AS title_id,
+ vat.id,
+ st.name,
+ vat.platform
+FROM
+ software_titles st
+LEFT JOIN
+ vpp_apps va
+ ON st.id = va.title_id
+LEFT JOIN
+ vpp_apps_teams vat
+ ON va.adam_id = vat.adam_id
+WHERE
+ vat.global_or_team_id = ?
+AND
+ st.id IN (%s)
+`, titleIDQuestionMarks)
+
+ stmtUnsetInstallers := `
+UPDATE software_installers
+SET install_during_setup = false
+WHERE global_or_team_id = ?`
+
+ stmtUnsetVPPAppsTeams := `
+UPDATE vpp_apps_teams vat
+SET install_during_setup = false
+WHERE global_or_team_id = ?`
+
+ stmtSetInstallers := `
+UPDATE software_installers
+SET install_during_setup = true
+WHERE id IN (%s)`
+
+ stmtSetVPPAppsTeams := `
+UPDATE vpp_apps_teams
+SET install_during_setup = true
+WHERE id IN (%s)`
+
+ if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ var softwareIDPlatforms []idPlatformTuple
+ var softwareIDs []any
+ var vppIDPlatforms []idPlatformTuple
+ var vppAppTeamIDs []any
+ // List of title IDs that were sent but aren't in the
+ // database. We add everything and then remove them
+ // from the list when we validate them below
+ missingTitleIDs := make(map[uint]struct{})
+ // Arguments used for queries that select vpp apps/installers
+ titleIDAndTeam := []any{teamID}
+ for _, id := range titleIDs {
+ missingTitleIDs[id] = struct{}{}
+ titleIDAndTeam = append(titleIDAndTeam, id)
+ }
+
+ // Select requested software installers
+ if len(titleIDs) > 0 {
+ if err := sqlx.SelectContext(ctx, tx, &softwareIDPlatforms, stmtSelectInstallersIDs, titleIDAndTeam...); err != nil {
+ return ctxerr.Wrap(ctx, err, "selecting software IDs using title IDs")
+ }
+ }
+
+ // Validate only macOS software
+ for _, tuple := range softwareIDPlatforms {
+ delete(missingTitleIDs, tuple.TitleID)
+ if tuple.Platform != string(fleet.MacOSPlatform) {
+ return ctxerr.Errorf(ctx, "only MacOS supported, unsupported software installer: %d (%s, %s)", tuple.ID, tuple.Name, tuple.Platform)
+ }
+ softwareIDs = append(softwareIDs, tuple.ID)
+ }
+
+ // Select requested VPP apps
+ if len(titleIDs) > 0 {
+ if err := sqlx.SelectContext(ctx, tx, &vppIDPlatforms, stmtSelectVPPAppsTeamsID, titleIDAndTeam...); err != nil {
+ return ctxerr.Wrap(ctx, err, "selecting vpp app team IDs using title IDs")
+ }
+ }
+
+ // Validate only macOS VPPP apps
+ for _, tuple := range vppIDPlatforms {
+ delete(missingTitleIDs, tuple.TitleID)
+ if tuple.Platform != string(fleet.MacOSPlatform) {
+ return ctxerr.Errorf(ctx, "only MacOS supported, unsupported AppStoreApp title: %d (%s, %s)", tuple.ID, tuple.Name, tuple.Platform)
+ }
+ vppAppTeamIDs = append(vppAppTeamIDs, tuple.ID)
+ }
+
+ // If we have any missing titles, return error
+ if len(missingTitleIDs) > 0 {
+ var keys []string
+ for k := range missingTitleIDs {
+ keys = append(keys, fmt.Sprintf("%d", k))
+ }
+ return ctxerr.Errorf(ctx, "title IDs not available: %s", strings.Join(keys, ","))
+ }
+
+ // Unset all installers
+ if _, err := tx.ExecContext(ctx, stmtUnsetInstallers, teamID); err != nil {
+ return ctxerr.Wrap(ctx, err, "unsetting software installers")
+ }
+
+ // Unset all vpp apps
+ if _, err := tx.ExecContext(ctx, stmtUnsetVPPAppsTeams, teamID); err != nil {
+ return ctxerr.Wrap(ctx, err, "unsetting vpp app teams")
+ }
+
+ if len(softwareIDs) > 0 {
+ stmtSetInstallersLoop := fmt.Sprintf(stmtSetInstallers, questionMarks(len(softwareIDs)))
+ if _, err := tx.ExecContext(ctx, stmtSetInstallersLoop, softwareIDs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "setting software installers")
+ }
+ }
+
+ if len(vppAppTeamIDs) > 0 {
+ stmtSetVPPAppsTeamsLoop := fmt.Sprintf(stmtSetVPPAppsTeams, questionMarks(len(vppAppTeamIDs)))
+ if _, err := tx.ExecContext(ctx, stmtSetVPPAppsTeamsLoop, vppAppTeamIDs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "setting vpp app teams")
+ }
+ }
+
+ return nil
+ }); err != nil {
+ return ctxerr.Wrap(ctx, err, "setting setup experience software")
+ }
+
+ return nil
+}
+
+func (ds *Datastore) ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
+ opts.IncludeMetadata = true
+ opts.After = ""
+
+ titles, count, meta, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{
+ TeamID: &teamID,
+ ListOptions: opts,
+ Platform: string(fleet.MacOSPlatform),
+ AvailableForInstall: true,
+ }, fleet.TeamFilter{
+ IncludeObserver: true,
+ TeamID: &teamID,
+ })
+ if err != nil {
+ return nil, 0, nil, ctxerr.Wrap(ctx, err, "calling list software titles")
+ }
+
+ return titles, count, meta, nil
+}
+
+type idPlatformTuple struct {
+ ID uint `db:"id"`
+ TitleID uint `db:"title_id"`
+ Name string `db:"name"`
+ Platform string `db:"platform"`
+}
+
+func questionMarks(number int) string {
+ return strings.Join(slices.Repeat([]string{"?"}, number), ",")
+}
+
+func (ds *Datastore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) {
+ const stmt = `
+SELECT
+ sesr.id,
+ sesr.host_uuid,
+ sesr.name,
+ sesr.status,
+ sesr.software_installer_id,
+ sesr.host_software_installs_execution_id,
+ sesr.vpp_app_team_id,
+ sesr.nano_command_uuid,
+ sesr.setup_experience_script_id,
+ sesr.script_execution_id,
+ sesr.error,
+ NULLIF(va.adam_id, '') AS vpp_app_adam_id,
+ NULLIF(va.platform, '') AS vpp_app_platform,
+ ses.script_content_id,
+ COALESCE(si.title_id, COALESCE(va.title_id, NULL)) AS software_title_id
+FROM setup_experience_status_results sesr
+LEFT JOIN setup_experience_scripts ses ON ses.id = sesr.setup_experience_script_id
+LEFT JOIN software_installers si ON si.id = sesr.software_installer_id
+LEFT JOIN vpp_apps_teams vat ON vat.id = sesr.vpp_app_team_id
+LEFT JOIN vpp_apps va ON vat.adam_id = va.adam_id
+WHERE host_uuid = ?
+ `
+ var results []*fleet.SetupExperienceStatusResult
+ if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostUUID); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "select setup experience status results by host uuid")
+ }
+ return results, nil
+}
+
+func (ds *Datastore) UpdateSetupExperienceStatusResult(ctx context.Context, status *fleet.SetupExperienceStatusResult) error {
+ const stmt = `
+UPDATE setup_experience_status_results
+SET
+ host_uuid = ?,
+ name = ?,
+ status = ?,
+ software_installer_id = ?,
+ host_software_installs_execution_id = ?,
+ vpp_app_team_id = ?,
+ nano_command_uuid = ?,
+ setup_experience_script_id = ?,
+ script_execution_id = ?,
+ error = ?
+WHERE id = ?
+`
+ if err := status.IsValid(); err != nil {
+ return ctxerr.Wrap(ctx, err, "invalid status update")
+ }
+
+ if _, err := ds.writer(ctx).ExecContext(
+ ctx,
+ stmt,
+ status.HostUUID,
+ status.Name,
+ status.Status,
+ status.SoftwareInstallerID,
+ status.HostSoftwareInstallsExecutionID,
+ status.VPPAppTeamID,
+ status.NanoCommandUUID,
+ status.SetupExperienceScriptID,
+ status.ScriptExecutionID,
+ status.Error,
+ status.ID,
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "updating setup experience status result")
+ }
+
+ return nil
+}
+
+func (ds *Datastore) GetSetupExperienceScript(ctx context.Context, teamID *uint) (*fleet.Script, error) {
+ query := `
+SELECT
+ id,
+ team_id,
+ name,
+ script_content_id,
+ created_at,
+ updated_at
+FROM
+ setup_experience_scripts
+WHERE
+ global_or_team_id = ?
+`
+ var globalOrTeamID uint
+ if teamID != nil {
+ globalOrTeamID = *teamID
+ }
+
+ var script fleet.Script
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &script, query, globalOrTeamID); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, ctxerr.Wrap(ctx, notFound("SetupExperienceScript"), "get setup experience script")
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get setup experience script")
+ }
+
+ return &script, nil
+}
+
+func (ds *Datastore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error {
+ err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ var err error
+
+ // first insert script contents
+ scRes, err := insertScriptContents(ctx, tx, script.ScriptContents)
+ if err != nil {
+ return err
+ }
+ id, _ := scRes.LastInsertId()
+
+ // then create the script entity
+ _, err = insertSetupExperienceScript(ctx, tx, script, uint(id)) // nolint: gosec
+ return err
+ })
+
+ return err
+}
+
+func insertSetupExperienceScript(ctx context.Context, tx sqlx.ExtContext, script *fleet.Script, scriptContentsID uint) (sql.Result, error) {
+ const insertStmt = `
+INSERT INTO
+ setup_experience_scripts (
+ team_id, global_or_team_id, name, script_content_id
+ )
+VALUES
+ (?, ?, ?, ?)
+`
+ var globalOrTeamID uint
+ if script.TeamID != nil {
+ globalOrTeamID = *script.TeamID
+ }
+ res, err := tx.ExecContext(ctx, insertStmt,
+ script.TeamID, globalOrTeamID, script.Name, scriptContentsID)
+ if err != nil {
+
+ if IsDuplicate(err) {
+ // already exists for this team/no team
+ err = &existsError{ResourceType: "SetupExperienceScript", TeamID: &globalOrTeamID}
+ } else if isChildForeignKeyError(err) {
+ // team does not exist
+ err = foreignKey("setup_experience_scripts", fmt.Sprintf("team_id=%v", script.TeamID))
+ }
+ return nil, ctxerr.Wrap(ctx, err, "insert setup experience script")
+ }
+
+ return res, nil
+}
+
+func (ds *Datastore) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
+ var globalOrTeamID uint
+ if teamID != nil {
+ globalOrTeamID = *teamID
+ }
+
+ _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM setup_experience_scripts WHERE global_or_team_id = ?`, globalOrTeamID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "delete setup experience script")
+ }
+
+ // NOTE: CleanupUnusedScriptContents is responsible for removing any orphaned script_contents
+ // for setup experience scripts.
+
+ return nil
+}
+
+func (ds *Datastore) SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, awaitingConfiguration bool) error {
+ return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ return setHostAwaitingConfiguration(ctx, tx, hostUUID, awaitingConfiguration)
+ })
+}
+
+func setHostAwaitingConfiguration(ctx context.Context, tx sqlx.ExtContext, hostUUID string, awaitingConfiguration bool) error {
+ const stmt = `
+INSERT INTO host_mdm_apple_awaiting_configuration (host_uuid, awaiting_configuration)
+VALUES (?, ?)
+ON DUPLICATE KEY UPDATE
+ awaiting_configuration = VALUES(awaiting_configuration)
+ `
+
+ _, err := tx.ExecContext(ctx, stmt, hostUUID, awaitingConfiguration)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "setting host awaiting configuration")
+ }
+
+ return nil
+}
+
+func (ds *Datastore) GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error) {
+ const stmt = `
+SELECT
+ awaiting_configuration
+FROM host_mdm_apple_awaiting_configuration
+WHERE host_uuid = ?
+ `
+ var awaitingConfiguration bool
+
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &awaitingConfiguration, stmt, hostUUID); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+
+ return false, ctxerr.Wrap(ctx, err, "getting host awaiting configuration")
+ }
+
+ return awaitingConfiguration, nil
+}
+
+func (ds *Datastore) MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, nanoCommandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND nano_command_uuid = ?"
+ updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?"
+
+ var id uint
+ if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, nanoCommandUUID); err != nil {
+ // TODO: maybe we can use the reader instead for this query
+ if errors.Is(err, sql.ErrNoRows) {
+ // return early if no results found
+ return false, nil
+ }
+ return false, err
+ }
+ res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+
+ return n > 0, nil
+}
+
+func (ds *Datastore) MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND host_software_installs_execution_id = ?"
+ updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?"
+
+ var id uint
+ if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil {
+ // TODO: maybe we can use the reader instead for this query
+ if errors.Is(err, sql.ErrNoRows) {
+ // return early if no results found
+ return false, nil
+ }
+ return false, err
+ }
+ res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+
+ return n > 0, nil
+}
+
+func (ds *Datastore) MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND script_execution_id = ?"
+ updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?"
+
+ var id uint
+ if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil {
+ // TODO: maybe we can use the reader instead for this query
+ if errors.Is(err, sql.ErrNoRows) {
+ // return early if no results found
+ return false, nil
+ }
+ return false, err
+ }
+ res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+
+ return n > 0, nil
+}
diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go
new file mode 100644
index 000000000000..5d56b71ffcb9
--- /dev/null
+++ b/server/datastore/mysql/setup_experience_test.go
@@ -0,0 +1,893 @@
+package mysql
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "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"
+)
+
+func TestSetupExperience(t *testing.T) {
+ ds := CreateMySQLDS(t)
+
+ cases := []struct {
+ name string
+ fn func(t *testing.T, ds *Datastore)
+ }{
+ {"EnqueueSetupExperienceItems", testEnqueueSetupExperienceItems},
+ {"GetSetupExperienceTitles", testGetSetupExperienceTitles},
+ {"SetSetupExperienceTitles", testSetSetupExperienceTitles},
+ {"ListSetupExperienceStatusResults", testSetupExperienceStatusResults},
+ {"SetupExperienceScriptCRUD", testSetupExperienceScriptCRUD},
+ {"TestHostInSetupExperience", testHostInSetupExperience},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ defer TruncateTables(t, ds)
+ c.fn(t, ds)
+ })
+ }
+}
+
+func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+ test.CreateInsertGlobalVPPToken(t, ds)
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+
+ team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
+ require.NoError(t, err)
+
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
+ installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "hello",
+ PreInstallQuery: "SELECT 1",
+ PostInstallScript: "world",
+ UninstallScript: "goodbye",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage1",
+ Filename: "file1",
+ Title: "Software1",
+ Version: "1.0",
+ Source: "apps",
+ UserID: user1.ID,
+ TeamID: &team1.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ require.NoError(t, err)
+
+ installerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "banana",
+ PreInstallQuery: "SELECT 3",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage3",
+ Filename: "file3",
+ Title: "Software2",
+ Version: "3.0",
+ Source: "apps",
+ SelfService: true,
+ UserID: user1.ID,
+ TeamID: &team2.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ require.NoError(t, err)
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2)
+ return err
+ })
+
+ app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
+ vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
+ require.NoError(t, err)
+
+ app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"}
+ vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team2.ID)
+ require.NoError(t, err)
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?)", vpp1.AdamID, vpp2.AdamID)
+ return err
+ })
+
+ var script1ID, script2ID int64
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ res, err := insertScriptContents(ctx, q, "SCRIPT 1")
+ if err != nil {
+ return err
+ }
+ id1, _ := res.LastInsertId()
+ res, err = insertScriptContents(ctx, q, "SCRIPT 2")
+ if err != nil {
+ return err
+ }
+ id2, _ := res.LastInsertId()
+
+ res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team1.ID, team1.ID, "script1", id1)
+ if err != nil {
+ return err
+ }
+ script1ID, _ = res.LastInsertId()
+
+ res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team2.ID, team2.ID, "script2", id2)
+ if err != nil {
+ return err
+ }
+ script2ID, _ = res.LastInsertId()
+
+ return nil
+ })
+
+ hostTeam1 := "123"
+ hostTeam2 := "456"
+ hostTeam3 := "789"
+
+ anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID)
+ require.NoError(t, err)
+ require.True(t, anythingEnqueued)
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID)
+ require.NoError(t, err)
+ require.True(t, anythingEnqueued)
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam3, team3.ID)
+ require.NoError(t, err)
+ require.False(t, anythingEnqueued)
+
+ seRows := []setupExperienceInsertTestRows{}
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results")
+ })
+
+ require.Len(t, seRows, 6)
+
+ for _, tc := range []setupExperienceInsertTestRows{
+ {
+ HostUUID: hostTeam1,
+ Name: "Software1",
+ Status: "pending",
+ SoftwareInstallerID: nullableUint(installerID1),
+ },
+ {
+ HostUUID: hostTeam2,
+ Name: "Software2",
+ Status: "pending",
+ SoftwareInstallerID: nullableUint(installerID2),
+ },
+ {
+ HostUUID: hostTeam1,
+ Name: app1.Name,
+ Status: "pending",
+ VPPAppTeamID: nullableUint(1),
+ },
+ {
+ HostUUID: hostTeam2,
+ Name: app2.Name,
+ Status: "pending",
+ VPPAppTeamID: nullableUint(2),
+ },
+ {
+ HostUUID: hostTeam1,
+ Name: "script1",
+ Status: "pending",
+ ScriptID: nullableUint(uint(script1ID)), // nolint: gosec
+ },
+ {
+ HostUUID: hostTeam2,
+ Name: "script2",
+ Status: "pending",
+ ScriptID: nullableUint(uint(script2ID)), // nolint: gosec
+ },
+ } {
+ var found bool
+ for _, row := range seRows {
+ if row == tc {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc)
+ }
+ }
+
+ for _, row := range seRows {
+ if row.HostUUID == hostTeam3 {
+ t.Error("team 3 shouldn't have any any entries")
+ }
+ }
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "DELETE FROM setup_experience_scripts WHERE global_or_team_id = ?", team2.ID)
+ if err != nil {
+ return err
+ }
+
+ _, err = q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID)
+ if err != nil {
+ return err
+ }
+
+ _, err = q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID)
+ require.NoError(t, err)
+ require.True(t, anythingEnqueued)
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID)
+ require.NoError(t, err)
+ require.False(t, anythingEnqueued)
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam3, team3.ID)
+ require.NoError(t, err)
+ require.False(t, anythingEnqueued)
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results")
+ })
+
+ require.Len(t, seRows, 3)
+
+ for _, tc := range []setupExperienceInsertTestRows{
+ {
+ HostUUID: hostTeam1,
+ Name: "Software1",
+ Status: "pending",
+ SoftwareInstallerID: nullableUint(installerID1),
+ },
+ {
+ HostUUID: hostTeam1,
+ Name: app1.Name,
+ Status: "pending",
+ VPPAppTeamID: nullableUint(1),
+ },
+ {
+ HostUUID: hostTeam1,
+ Name: "script1",
+ Status: "pending",
+ ScriptID: nullableUint(uint(script1ID)), // nolint: gosec
+ },
+ } {
+ var found bool
+ for _, row := range seRows {
+ if row == tc {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc)
+ }
+ }
+
+ for _, row := range seRows {
+ if row.HostUUID == hostTeam3 || row.HostUUID == hostTeam2 {
+ team := 2
+ if row.HostUUID == hostTeam3 {
+ team = 3
+ }
+ t.Errorf("team %d shouldn't have any any entries", team)
+ }
+ }
+}
+
+type setupExperienceInsertTestRows struct {
+ HostUUID string `db:"host_uuid"`
+ Name string `db:"name"`
+ Status string `db:"status"`
+ SoftwareInstallerID sql.NullInt64 `db:"software_installer_id"`
+ ScriptID sql.NullInt64 `db:"setup_experience_script_id"`
+ VPPAppTeamID sql.NullInt64 `db:"vpp_app_team_id"`
+}
+
+func nullableUint(val uint) sql.NullInt64 {
+ return sql.NullInt64{Int64: int64(val), Valid: true} // nolint: gosec
+}
+
+func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+ test.CreateInsertGlobalVPPToken(t, ds)
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
+ installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "hello",
+ PreInstallQuery: "SELECT 1",
+ PostInstallScript: "world",
+ UninstallScript: "goodbye",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage1",
+ Filename: "file1",
+ Title: "file1",
+ Version: "1.0",
+ Source: "apps",
+ UserID: user1.ID,
+ TeamID: &team1.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ require.NoError(t, err)
+
+ installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "banana",
+ PreInstallQuery: "SELECT 3",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage3",
+ Filename: "file3",
+ Title: "file3",
+ Version: "3.0",
+ Source: "apps",
+ SelfService: true,
+ UserID: user1.ID,
+ TeamID: &team2.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ require.NoError(t, err)
+
+ installerID4, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "pear",
+ PreInstallQuery: "SELECT 4",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello2")),
+ StorageID: "storage3",
+ Filename: "file4",
+ Title: "file4",
+ Version: "4.0",
+ Source: "apps",
+ SelfService: true,
+ UserID: user1.ID,
+ TeamID: &team2.ID,
+ Platform: string(fleet.IOSPlatform),
+ })
+ require.NoError(t, err)
+
+ titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 1)
+ assert.Equal(t, 1, count)
+ assert.NotNil(t, meta)
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?)", installerID1, installerID3, installerID4)
+ return err
+ })
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 1)
+ assert.Equal(t, installerID1, titles[0].ID)
+ assert.Equal(t, 1, count)
+ assert.NotNil(t, meta)
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 1)
+ assert.Equal(t, installerID3, titles[0].ID)
+ assert.Equal(t, 1, count)
+ assert.NotNil(t, meta)
+
+ app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
+ require.NoError(t, err)
+
+ app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
+ require.NoError(t, err)
+
+ app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
+ require.NoError(t, err)
+
+ vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
+ require.NoError(t, err)
+
+ vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
+ require.NoError(t, err)
+
+ vpp3, err := ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
+ require.NoError(t, err)
+
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?, ?)", vpp1.AdamID, vpp2.AdamID, vpp3.AdamID)
+ return err
+ })
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 2)
+ assert.Equal(t, vpp1.AdamID, titles[1].AppStoreApp.AppStoreID)
+ assert.Equal(t, 2, count)
+ assert.NotNil(t, meta)
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 2)
+ assert.Equal(t, vpp3.AdamID, titles[1].AppStoreApp.AppStoreID)
+ assert.Equal(t, 2, count)
+ assert.NotNil(t, meta)
+}
+
+func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+ test.CreateInsertGlobalVPPToken(t, ds)
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+
+ user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+
+ installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "hello",
+ PreInstallQuery: "SELECT 1",
+ PostInstallScript: "world",
+ UninstallScript: "goodbye",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage1",
+ Filename: "file1",
+ Title: "file1",
+ Version: "1.0",
+ Source: "apps",
+ UserID: user1.ID,
+ TeamID: &team1.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ _ = installerID1
+ require.NoError(t, err)
+
+ installerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "world",
+ PreInstallQuery: "SELECT 2",
+ PostInstallScript: "hello",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage2",
+ Filename: "file2",
+ Title: "file2",
+ Version: "2.0",
+ Source: "apps",
+ UserID: user1.ID,
+ TeamID: &team1.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ _ = installerID2
+ require.NoError(t, err)
+
+ installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "banana",
+ PreInstallQuery: "SELECT 3",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello")),
+ StorageID: "storage3",
+ Filename: "file3",
+ Title: "file3",
+ Version: "3.0",
+ Source: "apps",
+ SelfService: true,
+ UserID: user1.ID,
+ TeamID: &team2.ID,
+ Platform: string(fleet.MacOSPlatform),
+ })
+ _ = installerID3
+ require.NoError(t, err)
+
+ installerID4, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "pear",
+ PreInstallQuery: "SELECT 4",
+ PostInstallScript: "apple",
+ InstallerFile: bytes.NewReader([]byte("hello2")),
+ StorageID: "storage3",
+ Filename: "file4",
+ Title: "file4",
+ Version: "4.0",
+ Source: "apps",
+ SelfService: true,
+ UserID: user1.ID,
+ TeamID: &team2.ID,
+ Platform: string(fleet.IOSPlatform),
+ })
+ _ = installerID4
+ require.NoError(t, err)
+
+ titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 2)
+ assert.Equal(t, 2, count)
+ assert.NotNil(t, meta)
+
+ app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
+ require.NoError(t, err)
+
+ app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
+ require.NoError(t, err)
+
+ app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"}
+ _, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
+ require.NoError(t, err)
+
+ vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
+ _ = vpp1
+ require.NoError(t, err)
+
+ vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID)
+ _ = vpp2
+ require.NoError(t, err)
+
+ vpp3, err := ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID)
+ _ = vpp3
+ require.NoError(t, err)
+
+ titleSoftware := make(map[string]uint)
+ titleVPP := make(map[string]uint)
+
+ softwareTitles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team1.ID}, fleet.TeamFilter{TeamID: &team1.ID})
+ require.NoError(t, err)
+
+ for _, title := range softwareTitles {
+ if title.AppStoreApp != nil {
+ titleVPP[title.AppStoreApp.AppStoreID] = title.ID
+ } else if title.SoftwarePackage != nil {
+ titleSoftware[title.SoftwarePackage.Name] = title.ID
+ }
+ }
+
+ softwareTitles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team2.ID}, fleet.TeamFilter{TeamID: &team2.ID})
+ require.NoError(t, err)
+
+ for _, title := range softwareTitles {
+ if title.AppStoreApp != nil {
+ titleVPP[title.AppStoreApp.AppStoreID] = title.ID
+ } else if title.SoftwarePackage != nil {
+ titleSoftware[title.SoftwarePackage.Name] = title.ID
+ }
+ }
+
+ // Single installer
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleSoftware["file1"]})
+ require.NoError(t, err)
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 3)
+ assert.Equal(t, 3, count)
+ assert.Equal(t, "file1", titles[0].SoftwarePackage.Name)
+ assert.Equal(t, "file2", titles[1].SoftwarePackage.Name)
+ assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID)
+ assert.NotNil(t, meta)
+
+ assert.True(t, *titles[0].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup)
+
+ // Single vpp app replaces installer
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["1"]})
+ require.NoError(t, err)
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ require.Len(t, titles, 3)
+ require.Equal(t, 3, count)
+ assert.Equal(t, "file1", titles[0].SoftwarePackage.Name)
+ assert.Equal(t, "file2", titles[1].SoftwarePackage.Name)
+ assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID)
+ assert.NotNil(t, meta)
+
+ assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
+ assert.True(t, *titles[2].AppStoreApp.InstallDuringSetup)
+
+ // Team 2 unaffected
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ require.Len(t, titles, 2)
+ require.Equal(t, 2, count)
+ assert.Equal(t, "file3", titles[0].SoftwarePackage.Name)
+ assert.Equal(t, "3", titles[1].AppStoreApp.AppStoreID)
+ require.NotNil(t, meta)
+
+ assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[1].AppStoreApp.InstallDuringSetup)
+
+ // iOS software
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team2.ID, []uint{titleSoftware["file4"]})
+ require.ErrorContains(t, err, "unsupported")
+
+ // ios vpp app
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["2"]})
+ require.ErrorContains(t, err, "unsupported")
+
+ // wrong team
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["3"]})
+ require.ErrorContains(t, err, "not available")
+
+ // good other team assignment
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team2.ID, []uint{titleVPP["3"]})
+ require.NoError(t, err)
+
+ // non-existent title ID
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{999})
+ require.ErrorContains(t, err, "not available")
+
+ // Failures and other team assignments didn't affected the number of apps on team 1
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 3)
+ assert.Equal(t, 3, count)
+ assert.NotNil(t, meta)
+
+ // Empty slice removes all tiles
+ err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{})
+ require.NoError(t, err)
+
+ titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, titles, 3)
+ assert.Equal(t, 3, count)
+ assert.NotNil(t, meta)
+
+ assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup)
+ assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup)
+
+}
+
+func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+ hostUUID := uuid.NewString()
+
+ // Create a software installer
+ // We need a new user first
+ user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")})
+ require.NoError(t, err)
+ installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID})
+ require.NoError(t, err)
+ installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
+ require.NoError(t, err)
+
+ // VPP setup: create a token so that we can insert a VPP app
+ dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle")
+ require.NoError(t, err)
+ tok1, err := ds.InsertVPPToken(ctx, dataToken)
+ assert.NoError(t, err)
+ _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
+ assert.NoError(t, err)
+ vppApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{BundleIdentifier: "com.test.test", Name: "test.app", LatestVersion: "1.0.0"}, nil)
+ require.NoError(t, err)
+ var vppAppsTeamsID uint
+ err = sqlx.GetContext(context.Background(), ds.reader(ctx),
+ &vppAppsTeamsID, `SELECT id FROM vpp_apps_teams WHERE adam_id = ?`,
+ vppApp.AdamID,
+ )
+ require.NoError(t, err)
+
+ // TODO: use DS methods once those are written
+ var scriptID uint
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ res, err := q.ExecContext(ctx, `INSERT INTO setup_experience_scripts (name) VALUES (?)`,
+ "test_script")
+ require.NoError(t, err)
+ id, err := res.LastInsertId()
+ require.NoError(t, err)
+ scriptID = uint(id) // nolint: gosec
+ return nil
+ })
+
+ insertSetupExperienceStatusResult := func(sesr *fleet.SetupExperienceStatusResult) {
+ stmt := `INSERT INTO setup_experience_status_results (id, host_uuid, name, status, software_installer_id, host_software_installs_execution_id, vpp_app_team_id, nano_command_uuid, setup_experience_script_id, script_execution_id, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ res, err := q.ExecContext(ctx, stmt,
+ sesr.ID, sesr.HostUUID, sesr.Name, sesr.Status, sesr.SoftwareInstallerID, sesr.HostSoftwareInstallsExecutionID, sesr.VPPAppTeamID, sesr.NanoCommandUUID, sesr.SetupExperienceScriptID, sesr.ScriptExecutionID, sesr.Error)
+ require.NoError(t, err)
+ id, err := res.LastInsertId()
+ require.NoError(t, err)
+ sesr.ID = uint(id) // nolint: gosec
+ return nil
+ })
+ }
+
+ expRes := []*fleet.SetupExperienceStatusResult{
+ {
+ HostUUID: hostUUID,
+ Name: "software",
+ Status: fleet.SetupExperienceStatusPending,
+ SoftwareInstallerID: ptr.Uint(installerID),
+ SoftwareTitleID: installer.TitleID,
+ },
+ {
+ HostUUID: hostUUID,
+ Name: "vpp",
+ Status: fleet.SetupExperienceStatusPending,
+ VPPAppTeamID: ptr.Uint(vppAppsTeamsID),
+ SoftwareTitleID: ptr.Uint(vppApp.TitleID),
+ },
+ {
+ HostUUID: hostUUID,
+ Name: "script",
+ Status: fleet.SetupExperienceStatusPending,
+ SetupExperienceScriptID: ptr.Uint(scriptID),
+ },
+ }
+
+ for _, r := range expRes {
+ insertSetupExperienceStatusResult(r)
+ }
+
+ res, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID)
+ require.NoError(t, err)
+ require.Len(t, res, 3)
+ for i, s := range expRes {
+ require.Equal(t, s, res[i])
+ }
+}
+
+func testSetupExperienceScriptCRUD(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+
+ // create a script for team1
+ wantScript1 := &fleet.Script{
+ Name: "script",
+ TeamID: &team1.ID,
+ ScriptContents: "echo foo",
+ }
+
+ err = ds.SetSetupExperienceScript(ctx, wantScript1)
+ require.NoError(t, err)
+
+ // get the script for team1
+ gotScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, gotScript1)
+ require.Equal(t, wantScript1.Name, gotScript1.Name)
+ require.Equal(t, wantScript1.TeamID, gotScript1.TeamID)
+ require.NotZero(t, gotScript1.ScriptContentID)
+
+ b, err := ds.GetAnyScriptContents(ctx, gotScript1.ScriptContentID)
+ require.NoError(t, err)
+ require.Equal(t, wantScript1.ScriptContents, string(b))
+
+ // create a script for team2
+ wantScript2 := &fleet.Script{
+ Name: "script",
+ TeamID: &team2.ID,
+ ScriptContents: "echo bar",
+ }
+
+ err = ds.SetSetupExperienceScript(ctx, wantScript2)
+ require.NoError(t, err)
+
+ // get the script for team2
+ gotScript2, err := ds.GetSetupExperienceScript(ctx, &team2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, gotScript2)
+ require.Equal(t, wantScript2.Name, gotScript2.Name)
+ require.Equal(t, wantScript2.TeamID, gotScript2.TeamID)
+ require.NotZero(t, gotScript2.ScriptContentID)
+ require.NotEqual(t, gotScript1.ScriptContentID, gotScript2.ScriptContentID)
+
+ b, err = ds.GetAnyScriptContents(ctx, gotScript2.ScriptContentID)
+ require.NoError(t, err)
+ require.Equal(t, wantScript2.ScriptContents, string(b))
+
+ // create a script with no team id
+ wantScriptNoTeam := &fleet.Script{
+ Name: "script",
+ ScriptContents: "echo bar",
+ }
+
+ err = ds.SetSetupExperienceScript(ctx, wantScriptNoTeam)
+ require.NoError(t, err)
+
+ // get the script nil team id is equivalent to team id 0
+ gotScriptNoTeam, err := ds.GetSetupExperienceScript(ctx, nil)
+ require.NoError(t, err)
+ require.NotNil(t, gotScriptNoTeam)
+ require.Equal(t, wantScriptNoTeam.Name, gotScriptNoTeam.Name)
+ require.Nil(t, gotScriptNoTeam.TeamID)
+ require.NotZero(t, gotScriptNoTeam.ScriptContentID)
+ require.Equal(t, gotScript2.ScriptContentID, gotScriptNoTeam.ScriptContentID) // should be the same as team2
+
+ b, err = ds.GetAnyScriptContents(ctx, gotScriptNoTeam.ScriptContentID)
+ require.NoError(t, err)
+ require.Equal(t, wantScriptNoTeam.ScriptContents, string(b))
+
+ // try to create another with name "script" and no team id
+ var existsErr fleet.AlreadyExistsError
+ err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script", ScriptContents: "echo baz"})
+ require.Error(t, err)
+ require.ErrorAs(t, err, &existsErr)
+
+ // try to create another script with no team id and a different name
+ err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script2", ScriptContents: "echo baz"})
+ require.Error(t, err)
+ require.ErrorAs(t, err, &existsErr)
+
+ // try to add a script for a team that doesn't exist
+ var fkErr fleet.ForeignKeyError
+ err = ds.SetSetupExperienceScript(ctx, &fleet.Script{TeamID: ptr.Uint(42), Name: "script", ScriptContents: "echo baz"})
+ require.Error(t, err)
+ require.ErrorAs(t, err, &fkErr)
+
+ // delete the script for team1
+ err = ds.DeleteSetupExperienceScript(ctx, &team1.ID)
+ require.NoError(t, err)
+
+ // get the script for team1
+ _, err = ds.GetSetupExperienceScript(ctx, &team1.ID)
+ require.Error(t, err)
+ require.ErrorIs(t, err, sql.ErrNoRows)
+
+ // try to delete script for team1 again
+ err = ds.DeleteSetupExperienceScript(ctx, &team1.ID)
+ require.NoError(t, err) // TODO: confirm if we want to return not found on deletes
+
+ // try to delete script for team that doesn't exist
+ err = ds.DeleteSetupExperienceScript(ctx, ptr.Uint(42))
+ require.NoError(t, err) // TODO: confirm if we want to return not found on deletes
+
+ // add same script for team1 again
+ err = ds.SetSetupExperienceScript(ctx, wantScript1)
+ require.NoError(t, err)
+
+ // get the script for team1
+ oldScript1 := gotScript1
+ newScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, newScript1)
+ require.Equal(t, wantScript1.Name, newScript1.Name)
+ require.Equal(t, wantScript1.TeamID, newScript1.TeamID)
+ require.NotZero(t, newScript1.ScriptContentID)
+ // script contents are deleted by CleanupUnusedScriptContents not by DeleteSetupExperienceScript
+ // so the content id should be the same as the old
+ require.Equal(t, oldScript1.ScriptContentID, newScript1.ScriptContentID)
+}
+
+func testHostInSetupExperience(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+ err := ds.SetHostAwaitingConfiguration(ctx, "abc", true)
+ require.NoError(t, err)
+
+ inSetupExperience, err := ds.GetHostAwaitingConfiguration(ctx, "abc")
+ require.NoError(t, err)
+ require.True(t, inSetupExperience)
+
+ err = ds.SetHostAwaitingConfiguration(ctx, "abc", false)
+ require.NoError(t, err)
+
+ inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "abc")
+ require.NoError(t, err)
+ require.False(t, inSetupExperience)
+}
diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go
index 9ab5a1cfecc3..04069f9bf780 100644
--- a/server/datastore/mysql/software_installers.go
+++ b/server/datastore/mysql/software_installers.go
@@ -408,10 +408,14 @@ WHERE
return &dest, nil
}
-var errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
+var (
+ errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
+ errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience 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)
+ // allow delete only if install_during_setup is false
+ res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ? AND install_during_setup = 0`, id)
if err != nil {
if isMySQLForeignKey(err) {
// Check if the software installer is referenced by a policy automation.
@@ -428,6 +432,16 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error
rows, _ := res.RowsAffected()
if rows == 0 {
+ // could be that the software installer does not exist, or it is installed
+ // during setup, do additional check.
+ var installDuringSetup bool
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &installDuringSetup,
+ `SELECT install_during_setup FROM software_installers WHERE id = ?`, id); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return ctxerr.Wrap(ctx, err, "check if software installer is installed during setup")
+ }
+ if installDuringSetup {
+ return errDeleteInstallerInstalledDuringSetup
+ }
return notFound("SoftwareInstaller").WithID(id)
}
@@ -853,6 +867,17 @@ WHERE
)
`
+ const countInstallDuringSetupNotInList = `
+SELECT
+ COUNT(*)
+FROM
+ software_installers
+WHERE
+ global_or_team_id = ? AND
+ title_id NOT IN (?) AND
+ install_during_setup = 1
+`
+
const deleteInstallersNotInList = `
DELETE FROM
software_installers
@@ -865,7 +890,7 @@ WHERE
SELECT id,
storage_id != ? is_package_modified,
install_script_content_id != ? OR uninstall_script_content_id != ? OR pre_install_query != ? OR
-COALESCE(post_install_script_content_id != ? OR
+COALESCE(post_install_script_content_id != ? OR
(post_install_script_content_id IS NULL AND ? IS NOT NULL) OR
(? IS NULL AND post_install_script_content_id IS NOT NULL)
, FALSE) is_metadata_modified FROM software_installers
@@ -877,7 +902,7 @@ INSERT INTO software_installers (
team_id,
global_or_team_id,
storage_id,
- filename,
+ filename,
extension,
version,
install_script_content_id,
@@ -891,11 +916,12 @@ INSERT INTO software_installers (
user_name,
user_email,
url,
- package_ids
+ package_ids,
+ install_during_setup
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
(SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''),
- ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?
+ ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false)
)
ON DUPLICATE KEY UPDATE
install_script_content_id = VALUES(install_script_content_id),
@@ -911,7 +937,8 @@ ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
user_name = VALUES(user_name),
user_email = VALUES(user_email),
- url = VALUES(url)
+ url = VALUES(url),
+ install_during_setup = COALESCE(?, install_during_setup)
`
// use a team id of 0 if no-team
@@ -920,6 +947,15 @@ ON DUPLICATE KEY UPDATE
globalOrTeamID = *tmID
}
+ // if we're batch-setting installers and replacing the ones installed during
+ // setup in the same go, no need to validate that we don't delete one marked
+ // as install during setup (since we're overwriting those). This is always
+ // called from fleetctl gitops, so it should always be the case anyway.
+ var replacingInstallDuringSetup bool
+ if len(installers) == 0 || installers[0].InstallDuringSetup != nil {
+ replacingInstallDuringSetup = true
+ }
+
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// if no installers are provided, just delete whatever was in
// the table
@@ -959,6 +995,21 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrap(ctx, err, "unset obsolete software installers from policies")
}
+ // check if any in the list are install_during_setup, fail if there is one
+ if !replacingInstallDuringSetup {
+ stmt, args, err = sqlx.In(countInstallDuringSetupNotInList, globalOrTeamID, titleIDs)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "build statement to check installers install_during_setup")
+ }
+ var countInstallDuringSetup int
+ if err := sqlx.GetContext(ctx, tx, &countInstallDuringSetup, stmt, args...); err != nil {
+ return ctxerr.Wrap(ctx, err, "check installers installed during setup")
+ }
+ if countInstallDuringSetup > 0 {
+ return errDeleteInstallerInstalledDuringSetup
+ }
+ }
+
stmt, args, err = sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete obsolete installers")
@@ -1041,6 +1092,8 @@ ON DUPLICATE KEY UPDATE
installer.UserID,
installer.URL,
strings.Join(installer.PackageIDs, ","),
+ installer.InstallDuringSetup,
+ installer.InstallDuringSetup,
}
upsertQuery := insertNewOrEditedInstaller
if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package
diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go
index e5ff78d9c929..d33a7941c10e 100644
--- a/server/datastore/mysql/software_installers_test.go
+++ b/server/datastore/mysql/software_installers_test.go
@@ -32,7 +32,7 @@ func TestSoftwareInstallers(t *testing.T) {
{"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers},
{"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID},
{"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers},
- {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy},
+ {"DeleteSoftwareInstallers", testDeleteSoftwareInstallers},
{"GetHostLastInstallData", testGetHostLastInstallData},
{"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID},
}
@@ -719,21 +719,23 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
})
// add a new installer + ins0 installer
+ // mark ins0 as install_during_setup
ins1 := "installer1"
ins1File := bytes.NewReader([]byte("installer1"))
err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
{
- InstallScript: "install",
- InstallerFile: ins0File,
- StorageID: ins0,
- Filename: ins0,
- Title: ins0,
- Source: "apps",
- Version: "1",
- PreInstallQuery: "select 0 from foo;",
- UserID: user1.ID,
- Platform: "darwin",
- URL: "https://example.com",
+ InstallScript: "install",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: ins0,
+ Title: ins0,
+ Source: "apps",
+ Version: "1",
+ PreInstallQuery: "select 0 from foo;",
+ UserID: user1.ID,
+ Platform: "darwin",
+ URL: "https://example.com",
+ InstallDuringSetup: ptr.Bool(true),
},
{
InstallScript: "install",
@@ -767,6 +769,91 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
{Name: ins1, Source: "apps", Browser: ""},
})
+ // remove ins0 fails due to install_during_setup
+ err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
+ {
+ InstallScript: "install",
+ PostInstallScript: "post-install",
+ InstallerFile: ins1File,
+ StorageID: ins1,
+ Filename: ins1,
+ Title: ins1,
+ Source: "apps",
+ Version: "2",
+ PreInstallQuery: "select 1 from bar;",
+ UserID: user1.ID,
+ },
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup)
+
+ // batch-set both installers again, this time with nil install_during_setup for ins0,
+ // will keep it as true.
+ err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
+ {
+ InstallScript: "install",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: ins0,
+ Title: ins0,
+ Source: "apps",
+ Version: "1",
+ PreInstallQuery: "select 0 from foo;",
+ UserID: user1.ID,
+ Platform: "darwin",
+ URL: "https://example.com",
+ InstallDuringSetup: nil,
+ },
+ {
+ InstallScript: "install",
+ PostInstallScript: "post-install",
+ InstallerFile: ins1File,
+ StorageID: ins1,
+ Filename: ins1,
+ Title: ins1,
+ Source: "apps",
+ Version: "2",
+ PreInstallQuery: "select 1 from bar;",
+ UserID: user1.ID,
+ Platform: "darwin",
+ URL: "https://example2.com",
+ },
+ })
+ require.NoError(t, err)
+
+ // mark ins0 as NOT install_during_setup
+ err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
+ {
+ InstallScript: "install",
+ InstallerFile: ins0File,
+ StorageID: ins0,
+ Filename: ins0,
+ Title: ins0,
+ Source: "apps",
+ Version: "1",
+ PreInstallQuery: "select 0 from foo;",
+ UserID: user1.ID,
+ Platform: "darwin",
+ URL: "https://example.com",
+ InstallDuringSetup: ptr.Bool(false),
+ },
+ {
+ InstallScript: "install",
+ PostInstallScript: "post-install",
+ InstallerFile: ins1File,
+ StorageID: ins1,
+ Filename: ins1,
+ Title: ins1,
+ Source: "apps",
+ Version: "2",
+ PreInstallQuery: "select 1 from bar;",
+ UserID: user1.ID,
+ Platform: "darwin",
+ URL: "https://example2.com",
+ },
+ })
+ require.NoError(t, err)
+
// remove ins0
err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
{
@@ -958,7 +1045,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
assert.True(t, hasSelfService)
}
-func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) {
+func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) {
ctx := context.Background()
dir := t.TempDir()
@@ -1003,8 +1090,29 @@ func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) {
_, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID})
require.NoError(t, err)
+ // mark the installer as "installed during setup", which prevents deletion
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE software_installers SET install_during_setup = 1 WHERE id = ?`, softwareInstallerID)
+ return err
+ })
+
+ err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID)
+ require.Error(t, err)
+ require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup)
+
+ // clear "installed during setup", which allows deletion
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE software_installers SET install_during_setup = 0 WHERE id = ?`, softwareInstallerID)
+ return err
+ })
+
err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID)
require.NoError(t, err)
+
+ // deleting again returns an error, no such installer
+ err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID)
+ var nfe *notFoundError
+ require.ErrorAs(t, err, &nfe)
}
func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go
index e62cb4d7a76c..817b83308b4e 100644
--- a/server/datastore/mysql/software_titles.go
+++ b/server/datastore/mysql/software_titles.go
@@ -108,14 +108,16 @@ func (ds *Datastore) ListSoftwareTitles(
// grab titles that match the list options
type softwareTitle struct {
fleet.SoftwareTitleListResult
- PackageSelfService *bool `db:"package_self_service"`
- PackageName *string `db:"package_name"`
- PackageVersion *string `db:"package_version"`
- PackageURL *string `db:"package_url"`
- VPPAppSelfService *bool `db:"vpp_app_self_service"`
- VPPAppAdamID *string `db:"vpp_app_adam_id"`
- VPPAppVersion *string `db:"vpp_app_version"`
- VPPAppIconURL *string `db:"vpp_app_icon_url"`
+ PackageSelfService *bool `db:"package_self_service"`
+ PackageName *string `db:"package_name"`
+ PackageVersion *string `db:"package_version"`
+ PackageURL *string `db:"package_url"`
+ PackageInstallDuringSetup *bool `db:"package_install_during_setup"`
+ VPPAppSelfService *bool `db:"vpp_app_self_service"`
+ VPPAppAdamID *string `db:"vpp_app_adam_id"`
+ VPPAppVersion *string `db:"vpp_app_version"`
+ VPPAppIconURL *string `db:"vpp_app_icon_url"`
+ VPPInstallDuringSetup *bool `db:"vpp_install_during_setup"`
}
var softwareList []*softwareTitle
getTitlesStmt, args = appendListOptionsWithCursorToSQL(getTitlesStmt, args, &opt.ListOptions)
@@ -150,10 +152,11 @@ func (ds *Datastore) ListSoftwareTitles(
version = *title.PackageVersion
}
title.SoftwarePackage = &fleet.SoftwarePackageOrApp{
- Name: *title.PackageName,
- Version: version,
- SelfService: title.PackageSelfService,
- PackageURL: title.PackageURL,
+ Name: *title.PackageName,
+ Version: version,
+ SelfService: title.PackageSelfService,
+ PackageURL: title.PackageURL,
+ InstallDuringSetup: title.PackageInstallDuringSetup,
}
}
@@ -164,10 +167,11 @@ func (ds *Datastore) ListSoftwareTitles(
version = *title.VPPAppVersion
}
title.AppStoreApp = &fleet.SoftwarePackageOrApp{
- AppStoreID: *title.VPPAppAdamID,
- Version: version,
- SelfService: title.VPPAppSelfService,
- IconURL: title.VPPAppIconURL,
+ AppStoreID: *title.VPPAppAdamID,
+ Version: version,
+ SelfService: title.VPPAppSelfService,
+ IconURL: title.VPPAppIconURL,
+ InstallDuringSetup: title.VPPInstallDuringSetup,
}
}
@@ -265,8 +269,10 @@ SELECT
si.filename as package_name,
si.version as package_version,
si.url AS package_url,
+ si.install_during_setup as package_install_during_setup,
vat.self_service as vpp_app_self_service,
vat.adam_id as vpp_app_adam_id,
+ vat.install_during_setup as vpp_install_during_setup,
vap.latest_version as vpp_app_version,
vap.icon_url as vpp_app_icon_url
FROM software_titles st
@@ -280,7 +286,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND
WHERE %s
-- placeholder for filter based on software installed on hosts + software installers
AND (%s)
-GROUP BY st.id, package_self_service, package_name, package_version, package_url, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url`
+GROUP BY st.id, package_self_service, package_name, package_version, package_url, package_install_during_setup, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url, vpp_install_during_setup`
cveJoinType := "LEFT"
if opt.VulnerableOnly {
@@ -359,6 +365,11 @@ GROUP BY st.id, package_self_service, package_name, package_version, package_url
args = append(args, match, match)
}
+ if opt.Platform != "" {
+ additionalWhere += ` AND ( si.platform = ? OR vap.platform = ? )`
+ args = append(args, opt.Platform, opt.Platform)
+ }
+
// default to "a software installer or VPP app exists", and see next condition.
defaultFilter := fmt.Sprintf(`
((si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s)
diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go
index 175d49f8eed7..df4b7bc2d782 100644
--- a/server/datastore/mysql/teams_test.go
+++ b/server/datastore/mysql/teams_test.go
@@ -642,6 +642,8 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
BootstrapPackage: optjson.SetString("bootstrap"),
MacOSSetupAssistant: optjson.SetString("assistant"),
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}),
diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go
index a53fb79b9f2a..03a4c78706c5 100644
--- a/server/datastore/mysql/vpp.go
+++ b/server/datastore/mysql/vpp.go
@@ -169,10 +169,19 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets
return ctxerr.Wrap(ctx, err, "SetTeamVPPApps getting list of existing apps")
}
+ // if we're batch-setting apps and replacing the ones installed during setup
+ // in the same go, no need to validate that we don't delete one marked as
+ // install during setup (since we're overwriting those). This is always
+ // called from fleetctl gitops, so it should always be the case anyway.
+ var replacingInstallDuringSetup bool
+ if len(appFleets) == 0 || appFleets[0].InstallDuringSetup != nil {
+ replacingInstallDuringSetup = true
+ }
+
var toAddApps []fleet.VPPAppTeam
var toRemoveApps []fleet.VPPAppID
- for existingApp := range existingApps {
+ for existingApp, appTeamInfo := range existingApps {
var found bool
for _, appFleet := range appFleets {
// Self service value doesn't matter for removing app from team
@@ -181,12 +190,19 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets
}
}
if !found {
+ // if app is marked as install during setup, prevent deletion unless we're replacing those.
+ if !replacingInstallDuringSetup && appTeamInfo.InstallDuringSetup != nil && *appTeamInfo.InstallDuringSetup {
+ return errDeleteInstallerInstalledDuringSetup
+ }
toRemoveApps = append(toRemoveApps, existingApp)
}
}
for _, appFleet := range appFleets {
- if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService {
+ // upsert it if it does not exist or SelfService or InstallDuringSetup flags are changed
+ if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService ||
+ appFleet.InstallDuringSetup != nil &&
+ existingFleet.InstallDuringSetup != nil && *appFleet.InstallDuringSetup != *existingFleet.InstallDuringSetup {
toAddApps = append(toAddApps, appFleet)
}
}
@@ -250,7 +266,7 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp
func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) {
stmt := `
SELECT
- adam_id, platform, self_service
+ adam_id, platform, self_service, install_during_setup
FROM
vpp_apps_teams vat
WHERE
@@ -305,11 +321,13 @@ ON DUPLICATE KEY UPDATE
func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) error {
stmt := `
INSERT INTO vpp_apps_teams
- (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id)
+ (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id, install_during_setup)
VALUES
- (?, ?, ?, ?, ?, ?)
-ON DUPLICATE KEY UPDATE self_service = VALUES(self_service)
- `
+ (?, ?, ?, ?, ?, ?, COALESCE(?, false))
+ON DUPLICATE KEY UPDATE
+ self_service = VALUES(self_service),
+ install_during_setup = COALESCE(?, install_during_setup)
+`
var globalOrTmID uint
if teamID != nil {
@@ -320,7 +338,7 @@ ON DUPLICATE KEY UPDATE self_service = VALUES(self_service)
}
}
- _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID)
+ _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID, appID.InstallDuringSetup, appID.InstallDuringSetup)
if IsDuplicate(err) {
err = &existsError{
Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService),
@@ -415,7 +433,8 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
}
func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, appID fleet.VPPAppID) error {
- const stmt = `DELETE FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ?`
+ // allow delete only if install_during_setup is false
+ const stmt = `DELETE FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ? AND install_during_setup = 0`
var globalOrTeamID uint
if teamID != nil {
@@ -428,6 +447,16 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, app
rows, _ := res.RowsAffected()
if rows == 0 {
+ // could be that the VPP app does not exist, or it is installed during
+ // setup, do additional check.
+ var installDuringSetup bool
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &installDuringSetup,
+ `SELECT install_during_setup FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ?`, globalOrTeamID, appID.AdamID, appID.Platform); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return ctxerr.Wrap(ctx, err, "check if vpp app is installed during setup")
+ }
+ if installDuringSetup {
+ return errDeleteInstallerInstalledDuringSetup
+ }
return notFound("VPPApp").WithMessage(fmt.Sprintf("adam id %s platform %s for team id %d", appID.AdamID, appID.Platform,
globalOrTeamID))
}
diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go
index 78cab8acba22..a2f9ca9b5dbf 100644
--- a/server/datastore/mysql/vpp_test.go
+++ b/server/datastore/mysql/vpp_test.go
@@ -182,6 +182,16 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta)
+ // mark it as install_during_setup for team 2
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE global_or_team_id = ? AND adam_id = ?`, team2.ID, vpp2.AdamID)
+ return err
+ })
+ // this prevents its deletion
+ err = ds.DeleteVPPAppFromTeam(ctx, &team2.ID, vpp2)
+ require.Error(t, err)
+ require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup)
+
// delete vpp1 again fails, not found
err = ds.DeleteVPPAppFromTeam(ctx, nil, vpp1)
require.Error(t, err)
@@ -501,11 +511,17 @@ func testVPPApps(t *testing.T, ds *Datastore) {
// Check that getting the assigned apps works
appSet, err := ds.GetAssignedVPPApps(ctx, &team.ID)
require.NoError(t, err)
- assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{app1.VPPAppID: {VPPAppID: app1.VPPAppID}, app2.VPPAppID: {VPPAppID: app2.VPPAppID}}, appSet)
+ assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{
+ app1.VPPAppID: {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(false)},
+ app2.VPPAppID: {VPPAppID: app2.VPPAppID, InstallDuringSetup: ptr.Bool(false)},
+ }, appSet)
appSet, err = ds.GetAssignedVPPApps(ctx, nil)
require.NoError(t, err)
- assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{appNoTeam1.VPPAppID: {VPPAppID: appNoTeam1.VPPAppID}, appNoTeam2.VPPAppID: {VPPAppID: appNoTeam2.VPPAppID}}, appSet)
+ assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{
+ appNoTeam1.VPPAppID: {VPPAppID: appNoTeam1.VPPAppID, InstallDuringSetup: ptr.Bool(false)},
+ appNoTeam2.VPPAppID: {VPPAppID: appNoTeam2.VPPAppID, InstallDuringSetup: ptr.Bool(false)},
+ }, appSet)
var appTitles []fleet.SoftwareTitle
err = sqlx.SelectContext(ctx, ds.reader(ctx), &appTitles, `SELECT name, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?,?) ORDER BY bundle_identifier`, app1.BundleIdentifier, app2.BundleIdentifier)
@@ -531,7 +547,7 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) {
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
assert.NoError(t, err)
- // Insert some VPP apps for the team
+ // Insert some VPP apps for no team
app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
_, err = ds.InsertVPPAppWithTeam(ctx, app1, nil)
require.NoError(t, err)
@@ -550,8 +566,9 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) {
require.Len(t, assigned, 0)
// Assign 2 apps
+ // make app1 install_during_setup for that team
err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
- {VPPAppID: app1.VPPAppID},
+ {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)},
{VPPAppID: app2.VPPAppID, SelfService: true},
})
require.NoError(t, err)
@@ -562,10 +579,11 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) {
assert.Contains(t, assigned, app1.VPPAppID)
assert.Contains(t, assigned, app2.VPPAppID)
assert.True(t, assigned[app2.VPPAppID].SelfService)
+ assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup)
// Assign an additional app
err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
- {VPPAppID: app1.VPPAppID},
+ {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)},
{VPPAppID: app2.VPPAppID},
{VPPAppID: app3.VPPAppID},
})
@@ -578,10 +596,11 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) {
require.Contains(t, assigned, app2.VPPAppID)
require.Contains(t, assigned, app3.VPPAppID)
assert.False(t, assigned[app2.VPPAppID].SelfService)
+ assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup)
// Swap one app out for another
err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
- {VPPAppID: app1.VPPAppID},
+ {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)},
{VPPAppID: app2.VPPAppID, SelfService: true},
{VPPAppID: app4.VPPAppID},
})
@@ -594,6 +613,30 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) {
require.Contains(t, assigned, app2.VPPAppID)
require.Contains(t, assigned, app4.VPPAppID)
assert.True(t, assigned[app2.VPPAppID].SelfService)
+ assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup)
+
+ // Remove app1 fails because it is installed during setup
+ err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
+ {VPPAppID: app2.VPPAppID, SelfService: true},
+ {VPPAppID: app4.VPPAppID},
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup)
+
+ // make app1 NOT install_during_setup for that team
+ err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
+ {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(false)},
+ {VPPAppID: app2.VPPAppID, SelfService: true},
+ {VPPAppID: app4.VPPAppID},
+ })
+ require.NoError(t, err)
+
+ // Remove app1 now works
+ err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{
+ {VPPAppID: app2.VPPAppID, SelfService: true},
+ {VPPAppID: app4.VPPAppID},
+ })
+ require.NoError(t, err)
// Remove all apps
err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{})
diff --git a/server/fleet/app.go b/server/fleet/app.go
index f0810e352df6..483dd5fe796a 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -420,10 +420,19 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro
// MacOSSetup contains settings related to the setup of DEP enrolled devices.
type MacOSSetup struct {
- BootstrapPackage optjson.String `json:"bootstrap_package"`
- EnableEndUserAuthentication bool `json:"enable_end_user_authentication"`
- MacOSSetupAssistant optjson.String `json:"macos_setup_assistant"`
- EnableReleaseDeviceManually optjson.Bool `json:"enable_release_device_manually"`
+ BootstrapPackage optjson.String `json:"bootstrap_package"`
+ EnableEndUserAuthentication bool `json:"enable_end_user_authentication"`
+ MacOSSetupAssistant optjson.String `json:"macos_setup_assistant"`
+ EnableReleaseDeviceManually optjson.Bool `json:"enable_release_device_manually"`
+ Script optjson.String `json:"script"`
+ Software optjson.Slice[*MacOSSetupSoftware] `json:"software"`
+}
+
+// MacOSSetupSoftware represents a VPP app or a software package to install
+// during the setup experience of a macOS device.
+type MacOSSetupSoftware struct {
+ AppStoreID string `json:"app_store_id"`
+ PackagePath string `json:"package_path"`
}
// MacOSMigration contains settings related to the MDM migration work flow.
@@ -670,6 +679,15 @@ func (c *AppConfig) Copy() *AppConfig {
clone.MDM.VolumePurchasingProgram = optjson.SetSlice(vpp)
}
+ if c.MDM.MacOSSetup.Software.Set {
+ sw := make([]*MacOSSetupSoftware, len(c.MDM.MacOSSetup.Software.Value))
+ for i, s := range c.MDM.MacOSSetup.Software.Value {
+ s := *s
+ sw[i] = &s
+ }
+ clone.MDM.MacOSSetup.Software = optjson.SetSlice(sw)
+ }
+
return &clone
}
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index e73159d7c5db..84ab0f7636aa 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -24,6 +24,7 @@ type MDMAppleCommandIssuer interface {
EraseDevice(ctx context.Context, host *Host, uuid string) error
InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error
InstallApplication(ctx context.Context, hostUUIDs []string, uuid string, adamID string) error
+ DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error
}
// MDMAppleEnrollmentType is the type for Apple MDM enrollments.
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 415334b6c831..93a4d6ff2207 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -1746,7 +1746,32 @@ type Datastore interface {
GetVPPTokenByLocation(ctx context.Context, loc string) (*VPPTokenDB, error)
+ ////////////////////////////////////////////////////////////////////////////////////
+ // Setup Experience
+ SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error
+ ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts ListOptions) ([]SoftwareTitleListResult, int, *PaginationMetadata, error)
+
+ // SetHostAwaitingConfiguration sets a boolean indicating whether or not the given host is
+ // in the setup experience flow (which runs during macOS Setup Assistant).
+ SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, inSetupExperience bool) error
+ // GetHostAwaitingConfiguration returns a boolean indicating whether or not the given host is
+ // in the setup experience flow (which runs during macOS Setup Assistant).
+ GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error)
+
///////////////////////////////////////////////////////////////////////////////
+ // Setup Experience
+ //
+
+ ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*SetupExperienceStatusResult, error)
+ UpdateSetupExperienceStatusResult(ctx context.Context, status *SetupExperienceStatusResult) error
+ EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error)
+ GetSetupExperienceScript(ctx context.Context, teamID *uint) (*Script, error)
+ SetSetupExperienceScript(ctx context.Context, script *Script) error
+ DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error
+ MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error)
+ MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error)
+ MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, commandUUID string, status SetupExperienceStatusResultStatus) (bool, error)
+
// Fleet-maintained apps
//
diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go
index 936c2678c987..1d340b1e7b34 100644
--- a/server/fleet/orbit.go
+++ b/server/fleet/orbit.go
@@ -36,6 +36,10 @@ type OrbitConfigNotifications struct {
// PendingSoftwareInstallerIDs contains a list of software install_ids queued for installation
PendingSoftwareInstallerIDs []string `json:"pending_software_installer_ids,omitempty"`
+
+ // RunSetupExperience indicates whether or not Orbit should run the Fleet setup experience
+ // during macOS Setup Assistant.
+ RunSetupExperience bool `json:"run_setup_experience,omitempty"`
}
type OrbitConfig struct {
diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go
index 1dcbb343e9bc..28bba6ad8cf7 100644
--- a/server/fleet/scripts.go
+++ b/server/fleet/scripts.go
@@ -373,14 +373,15 @@ type ScriptPayload struct {
}
type SoftwareInstallerPayload struct {
- URL string `json:"url"`
- PreInstallQuery string `json:"pre_install_query"`
- InstallScript string `json:"install_script"`
- UninstallScript string `json:"uninstall_script"`
- PostInstallScript string `json:"post_install_script"`
- SelfService bool `json:"self_service"`
- FleetMaintained bool `json:"-"`
- Filename string `json:"-"`
+ URL string `json:"url"`
+ PreInstallQuery string `json:"pre_install_query"`
+ InstallScript string `json:"install_script"`
+ UninstallScript string `json:"uninstall_script"`
+ PostInstallScript string `json:"post_install_script"`
+ SelfService bool `json:"self_service"`
+ FleetMaintained bool `json:"-"`
+ Filename string `json:"-"`
+ InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
}
type HostLockWipeStatus struct {
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 029f22cbdc77..e7f70e807260 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -35,6 +35,7 @@ type EnterpriseOverrides struct {
MDMWindowsEnableOSUpdates func(ctx context.Context, teamID *uint, updates WindowsUpdates) error
MDMWindowsDisableOSUpdates func(ctx context.Context, teamID *uint) error
MDMAppleEditedAppleOSUpdates func(ctx context.Context, teamID *uint, appleDevice AppleDevice, updates AppleOSUpdateSettings) error
+ SetupExperienceNextStep func(ctx context.Context, hostUUID string) (bool, error)
}
type OsqueryService interface {
@@ -1120,6 +1121,24 @@ type Service interface {
teamID *uint) (*DownloadSoftwareInstallerPayload, error)
OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*DownloadSoftwareInstallerPayload, error)
+ ////////////////////////////////////////////////////////////////////////////////
+ // Setup Experience
+
+ SetSetupExperienceSoftware(ctx context.Context, teamID uint, titleIDs []uint) error
+ ListSetupExperienceSoftware(ctx context.Context, teamID uint, opts ListOptions) ([]SoftwareTitleListResult, int, *PaginationMetadata, error)
+ // GetOrbitSetupExperienceStatus gets the current status of a macOS setup experience for the given host.
+ GetOrbitSetupExperienceStatus(ctx context.Context, orbitNodeKey string, forceRelease bool) (*SetupExperienceStatusPayload, error)
+ // GetSetupExperienceScript gets the current setup experience script for the given team.
+ GetSetupExperienceScript(ctx context.Context, teamID *uint, downloadRequested bool) (*Script, []byte, error)
+ // SetSetupExperienceScript sets the setup experience script for the given team.
+ SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error
+ // DeleteSetupExperienceScript deletes the setup experience script for the given team.
+ DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error
+ // SetupExperienceNextStep is a callback that processes the
+ // setup experience status results table and enqueues the next
+ // step. It returns true when there is nothing left to do (setup finished)
+ SetupExperienceNextStep(ctx context.Context, hostUUID string) (bool, error)
+
///////////////////////////////////////////////////////////////////////////////
// Fleet-maintained apps
diff --git a/server/fleet/setup_experience.go b/server/fleet/setup_experience.go
new file mode 100644
index 000000000000..7486e776e5a8
--- /dev/null
+++ b/server/fleet/setup_experience.go
@@ -0,0 +1,191 @@
+package fleet
+
+import (
+ "errors"
+ "fmt"
+)
+
+type SetupExperienceStatusResultStatus string
+
+const (
+ SetupExperienceStatusPending SetupExperienceStatusResultStatus = "pending"
+ SetupExperienceStatusRunning SetupExperienceStatusResultStatus = "running"
+ SetupExperienceStatusSuccess SetupExperienceStatusResultStatus = "success"
+ SetupExperienceStatusFailure SetupExperienceStatusResultStatus = "failure"
+)
+
+func (s SetupExperienceStatusResultStatus) IsValid() bool {
+ switch s {
+ case SetupExperienceStatusPending, SetupExperienceStatusRunning, SetupExperienceStatusSuccess, SetupExperienceStatusFailure:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s SetupExperienceStatusResultStatus) IsTerminalStatus() bool {
+ switch s {
+ case SetupExperienceStatusSuccess, SetupExperienceStatusFailure:
+ return true
+ default:
+ return false
+ }
+}
+
+// SetupExperienceStatusResult represents the status of a particular step in the macOS setup
+// experience process for a particular host. These steps can either be a software installer
+// installation, a VPP app installation, or a script execution.
+type SetupExperienceStatusResult struct {
+ ID uint `db:"id" json:"-" `
+ HostUUID string `db:"host_uuid" json:"-" `
+ Name string `db:"name" json:"name,omitempty" `
+ Status SetupExperienceStatusResultStatus `db:"status" json:"status,omitempty" `
+ SoftwareInstallerID *uint `db:"software_installer_id" json:"-" `
+ HostSoftwareInstallsExecutionID *string `db:"host_software_installs_execution_id" json:"-" `
+ VPPAppTeamID *uint `db:"vpp_app_team_id" json:"-" `
+ VPPAppAdamID *string `db:"vpp_app_adam_id" json:"-"`
+ VPPAppPlatform *string `db:"vpp_app_platform" json:"-"`
+ NanoCommandUUID *string `db:"nano_command_uuid" json:"-" `
+ SetupExperienceScriptID *uint `db:"setup_experience_script_id" json:"-" `
+ ScriptContentID *uint `db:"script_content_id" json:"-"`
+ ScriptExecutionID *string `db:"script_execution_id" json:"execution_id,omitempty" `
+ Error *string `db:"error" json:"-" `
+ // SoftwareTitleID must be filled through a JOIN
+ SoftwareTitleID *uint `json:"software_title_id,omitempty" db:"software_title_id"`
+}
+
+func (s *SetupExperienceStatusResult) IsValid() error {
+ var colsSet uint
+ if s.SoftwareInstallerID != nil {
+ colsSet++
+ if s.NanoCommandUUID != nil || s.ScriptExecutionID != nil {
+ return fmt.Errorf("invalid setup experience staus row, software_installer_id set with incorrect secondary value column: %d", s.ID)
+ }
+ }
+ if s.VPPAppTeamID != nil {
+ colsSet++
+ if s.HostSoftwareInstallsExecutionID != nil || s.ScriptExecutionID != nil {
+ return fmt.Errorf("invalid setup experience staus row, vpp_app_team set with incorrect secondary value column: %d", s.ID)
+ }
+ }
+ if s.SetupExperienceScriptID != nil {
+ colsSet++
+ if s.HostSoftwareInstallsExecutionID != nil || s.NanoCommandUUID != nil {
+ return fmt.Errorf("invalid setup experience staus row, setip_experience_script_id set with incorrect secondary value column: %d", s.ID)
+ }
+ }
+ if colsSet > 1 {
+ return fmt.Errorf("invalid setup experience status row, multiple underlying value columns set: %d", s.ID)
+ }
+ if colsSet == 0 {
+ return fmt.Errorf("invalid setup experience status row, no underlying value colunm set: %d", s.ID)
+ }
+
+ return nil
+
+}
+
+func (s *SetupExperienceStatusResult) VPPAppID() (*VPPAppID, error) {
+ if s.VPPAppAdamID == nil || s.VPPAppPlatform == nil {
+ return nil, errors.New("not a VPP app")
+ }
+
+ return &VPPAppID{
+ AdamID: *s.VPPAppAdamID,
+ Platform: AppleDevicePlatform(*s.VPPAppPlatform),
+ }, nil
+}
+
+// IsForScript indicates if this result is for a setup experience script step.
+func (s *SetupExperienceStatusResult) IsForScript() bool {
+ return s.SetupExperienceScriptID != nil
+}
+
+// IsForSoftware indicates if this result is for a setup experience software step: either a software
+// installer or a VPP app.
+func (s *SetupExperienceStatusResult) IsForSoftware() bool {
+ return s.VPPAppTeamID != nil || s.SoftwareInstallerID != nil
+}
+
+type SetupExperienceBootstrapPackageResult struct {
+ Name string `json:"name"`
+ Status MDMBootstrapPackageStatus `json:"status"`
+}
+
+type SetupExperienceConfigurationProfileResult struct {
+ ProfileUUID string `json:"profile_uuid"`
+ Name string `json:"name"`
+ Status MDMDeliveryStatus `json:"status"`
+}
+
+type SetupExperienceAccountConfigurationResult struct {
+ CommandUUID string `json:"command_uuid"`
+ Status string `json:"status"`
+}
+
+type SetupExperienceVPPInstallResult struct {
+ HostUUID string
+ CommandUUID string
+ CommandStatus string
+}
+
+func (r SetupExperienceVPPInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
+ switch r.CommandStatus {
+ case MDMAppleStatusAcknowledged:
+ return SetupExperienceStatusSuccess
+ case MDMAppleStatusError, MDMAppleStatusCommandFormatError:
+ return SetupExperienceStatusFailure
+ default:
+ // TODO: is this what we want as the default, what about other possible statuses?
+ return SetupExperienceStatusPending
+ }
+}
+
+type SetupExperienceSoftwareInstallResult struct {
+ HostUUID string
+ ExecutionID string
+ InstallerStatus SoftwareInstallerStatus
+}
+
+func (r SetupExperienceSoftwareInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
+ switch r.InstallerStatus {
+ case SoftwareInstalled:
+ return SetupExperienceStatusSuccess
+ case SoftwareFailed, SoftwareInstallFailed:
+ return SetupExperienceStatusFailure
+ default:
+ // TODO: is this what we want as the default, what about other possible statuses (uninstall)?
+ return SetupExperienceStatusPending
+ }
+}
+
+type SetupExperienceScriptResult struct {
+ HostUUID string
+ ExecutionID string
+ ExitCode int
+}
+
+func (r SetupExperienceScriptResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
+ if r.ExitCode == 0 {
+ return SetupExperienceStatusSuccess
+ }
+ // TODO: what about other possible script statuses? seems like pending/running is never a
+ // possibility here (exit code can't be null)?
+ return SetupExperienceStatusFailure
+}
+
+// SetupExperienceStatusPayload is the payload we send to Orbit to tell it what the current status
+// of the setup experience is for that host.
+type SetupExperienceStatusPayload struct {
+ Script *SetupExperienceStatusResult `json:"script,omitempty"`
+ Software []*SetupExperienceStatusResult `json:"software,omitempty"`
+ BootstrapPackage *SetupExperienceBootstrapPackageResult `json:"bootstrap_package,omitempty"`
+ ConfigurationProfiles []*SetupExperienceConfigurationProfileResult `json:"configuration_profiles,omitempty"`
+ AccountConfiguration *SetupExperienceAccountConfigurationResult `json:"account_configuration,omitempty"`
+ OrgLogoURL string `json:"org_logo_url"`
+}
+
+func IsSetupExperienceSupported(hostPlatform string) bool {
+ // TODO: confirm we aren't supporting any other Apple platforms
+ return hostPlatform == "darwin"
+}
diff --git a/server/fleet/setup_experience_test.go b/server/fleet/setup_experience_test.go
new file mode 100644
index 000000000000..8117d049aff9
--- /dev/null
+++ b/server/fleet/setup_experience_test.go
@@ -0,0 +1,144 @@
+package fleet
+
+import (
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSetupExperienceStatusResultIsValid(t *testing.T) {
+ id := ptr.Uint(1)
+ str := ptr.String("x")
+ for _, tc := range []struct {
+ Name string
+ Case SetupExperienceStatusResult
+ Valid bool
+ }{
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ },
+ Valid: true,
+ Name: "just software installer",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ HostSoftwareInstallsExecutionID: str,
+ },
+ Valid: true,
+ Name: "software and result",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ NanoCommandUUID: str,
+ },
+ Valid: false,
+ Name: "installer and vpp secondary",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ ScriptExecutionID: str,
+ },
+ Valid: false,
+ Name: "installer and script secondary",
+ },
+
+ {
+ Case: SetupExperienceStatusResult{
+ VPPAppTeamID: id,
+ },
+ Valid: true,
+ Name: "just vpp app team",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ VPPAppTeamID: id,
+ NanoCommandUUID: str,
+ },
+ Valid: true,
+ Name: "vpp app and result",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ VPPAppTeamID: id,
+ HostSoftwareInstallsExecutionID: str,
+ },
+ Valid: false,
+ Name: "vpp and installer secondary",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ VPPAppTeamID: id,
+ ScriptExecutionID: str,
+ },
+ Valid: false,
+ Name: "vpp and script secondary",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SetupExperienceScriptID: id,
+ },
+ Valid: true,
+ Name: "just script id",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SetupExperienceScriptID: id,
+ ScriptExecutionID: str,
+ },
+ Valid: true,
+ Name: "script and result",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SetupExperienceScriptID: id,
+ HostSoftwareInstallsExecutionID: str,
+ },
+ Valid: false,
+ Name: "script and installer secondary",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SetupExperienceScriptID: id,
+ NanoCommandUUID: str,
+ },
+ Valid: false,
+ Name: "script and vpp secondary",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ VPPAppTeamID: id,
+ },
+ Valid: false,
+ Name: "installer and vpp",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ SoftwareInstallerID: id,
+ SetupExperienceScriptID: id,
+ },
+ Valid: false,
+ Name: "installer and script",
+ },
+ {
+ Case: SetupExperienceStatusResult{
+ VPPAppTeamID: id,
+ SetupExperienceScriptID: id,
+ },
+ Valid: false,
+ Name: "vpp and script",
+ },
+ } {
+ err := tc.Case.IsValid()
+ if tc.Valid {
+ require.NoError(t, err, tc.Name)
+ } else {
+ require.Error(t, err, tc.Name)
+ }
+ }
+}
diff --git a/server/fleet/software.go b/server/fleet/software.go
index a75538d03b56..0f9eeab4f21f 100644
--- a/server/fleet/software.go
+++ b/server/fleet/software.go
@@ -227,6 +227,7 @@ type SoftwareTitleListOptions struct {
MinimumCVSS float64 `query:"min_cvss_score,optional"`
MaximumCVSS float64 `query:"max_cvss_score,optional"`
PackagesOnly bool `query:"packages_only,optional"`
+ Platform string `query:"platform,optional"`
}
type HostSoftwareTitleListOptions struct {
@@ -426,12 +427,14 @@ func SoftwareFromOsqueryRow(name, version, source, vendor, installedPath, releas
}
type VPPBatchPayload struct {
- AppStoreID string `json:"app_store_id"`
- SelfService bool `json:"self_service"`
+ AppStoreID string `json:"app_store_id"`
+ SelfService bool `json:"self_service"`
+ InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated
}
type VPPBatchPayloadWithPlatform struct {
- AppStoreID string `json:"app_store_id"`
- SelfService bool `json:"self_service"`
- Platform AppleDevicePlatform `json:"platform"`
+ AppStoreID string `json:"app_store_id"`
+ SelfService bool `json:"self_service"`
+ Platform AppleDevicePlatform `json:"platform"`
+ InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated
}
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index 47e7ed91fe89..d715e1ddc5cf 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -308,25 +308,26 @@ func (s *HostSoftwareInstallerResultAuthz) AuthzType() string {
}
type UploadSoftwareInstallerPayload struct {
- TeamID *uint
- InstallScript string
- PreInstallQuery string
- PostInstallScript string
- InstallerFile io.ReadSeeker // TODO: maybe pull this out of the payload and only pass it to methods that need it (e.g., won't be needed when storing metadata in the database)
- StorageID string
- Filename string
- Title string
- Version string
- Source string
- Platform string
- BundleIdentifier string
- SelfService bool
- UserID uint
- URL string
- FleetLibraryAppID *uint
- PackageIDs []string
- UninstallScript string
- Extension string
+ TeamID *uint
+ InstallScript string
+ PreInstallQuery string
+ PostInstallScript string
+ InstallerFile io.ReadSeeker // TODO: maybe pull this out of the payload and only pass it to methods that need it (e.g., won't be needed when storing metadata in the database)
+ StorageID string
+ Filename string
+ Title string
+ Version string
+ Source string
+ Platform string
+ BundleIdentifier string
+ SelfService bool
+ UserID uint
+ URL string
+ FleetLibraryAppID *uint
+ PackageIDs []string
+ UninstallScript string
+ Extension string
+ InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
}
type UpdateSoftwareInstallerPayload struct {
@@ -426,6 +427,9 @@ type SoftwarePackageOrApp struct {
LastInstall *HostSoftwareInstall `json:"last_install"`
LastUninstall *HostSoftwareUninstall `json:"last_uninstall"`
PackageURL *string `json:"package_url"`
+ // InstallDuringSetup is a boolean that indicates if the package
+ // will be installed during the macos setup experience.
+ InstallDuringSetup *bool `json:"install_during_setup,omitempty" db:"install_during_setup"`
}
type SoftwarePackageSpec struct {
@@ -435,6 +439,16 @@ type SoftwarePackageSpec struct {
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
+
+ // ReferencedYamlPath is the resolved path of the file used to fill the
+ // software package. Only present after parsing a GitOps file on the fleetctl
+ // side of processing. This is required to match a macos_setup.software to
+ // its corresponding software package, as we do this matching by yaml path.
+ //
+ // It must be JSON-marshaled because it gets set during gitops file processing,
+ // which is then re-marshaled to JSON from this struct and later re-unmarshaled
+ // during ApplyGroup...
+ ReferencedYamlPath string `json:"referenced_yaml_path"`
}
type SoftwareSpec struct {
diff --git a/server/fleet/teams.go b/server/fleet/teams.go
index 68cde3072dcc..4ee8b53aad65 100644
--- a/server/fleet/teams.go
+++ b/server/fleet/teams.go
@@ -239,6 +239,14 @@ func (t *TeamMDM) Copy() *TeamMDM {
}
clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings)
}
+ if t.MacOSSetup.Software.Set {
+ sw := make([]*MacOSSetupSoftware, len(t.MacOSSetup.Software.Value))
+ for i, s := range t.MacOSSetup.Software.Value {
+ s := *s
+ sw[i] = &s
+ }
+ clone.MacOSSetup.Software = optjson.SetSlice(sw)
+ }
return &clone
}
@@ -518,5 +526,7 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) {
HostExpirySettings: &t.Config.HostExpirySettings,
WebhookSettings: webhookSettings,
Integrations: integrations,
+ Scripts: t.Config.Scripts,
+ Software: t.Config.Software,
}, nil
}
diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go
index 4e0803edfea6..a5ecc83ca6ef 100644
--- a/server/fleet/vpp.go
+++ b/server/fleet/vpp.go
@@ -17,6 +17,12 @@ type VPPAppTeam struct {
VPPAppID
SelfService bool `db:"self_service" json:"self_service"`
+
+ // InstallDuringSetup is either the stored value of that flag for the VPP app
+ // or the value to set to that VPP app when batch-setting it. When used to
+ // set the value, if nil it will keep the currently saved value (or default
+ // to false), while if not nil, it will update the flag's value in the DB.
+ InstallDuringSetup *bool `db:"install_during_setup" json:"-"`
}
// VPPApp represents a VPP (Volume Purchase Program) application,
diff --git a/server/mdm/nanomdm/mdm/mdm.go b/server/mdm/nanomdm/mdm/mdm.go
index e3ec762bd39a..ac429a69b0e5 100644
--- a/server/mdm/nanomdm/mdm/mdm.go
+++ b/server/mdm/nanomdm/mdm/mdm.go
@@ -9,12 +9,13 @@ import (
// Enrollment represents the various enrollment-related data sent with requests.
type Enrollment struct {
- UDID string `plist:",omitempty"`
- UserID string `plist:",omitempty"`
- UserShortName string `plist:",omitempty"`
- UserLongName string `plist:",omitempty"`
- EnrollmentID string `plist:",omitempty"`
- EnrollmentUserID string `plist:",omitempty"`
+ AwaitingConfiguration bool `plist:",omitempty"`
+ UDID string `plist:",omitempty"`
+ UserID string `plist:",omitempty"`
+ UserShortName string `plist:",omitempty"`
+ UserLongName string `plist:",omitempty"`
+ EnrollmentID string `plist:",omitempty"`
+ EnrollmentUserID string `plist:",omitempty"`
}
// EnrollID contains the custom enrollment IDs derived from enrollment
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 66d60f95368e..93b125a007ad 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -1101,6 +1101,32 @@ type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandRe
type GetVPPTokenByLocationFunc func(ctx context.Context, loc string) (*fleet.VPPTokenDB, error)
+type SetSetupExperienceSoftwareTitlesFunc func(ctx context.Context, teamID uint, titleIDs []uint) error
+
+type ListSetupExperienceSoftwareTitlesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error)
+
+type SetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string, inSetupExperience bool) error
+
+type GetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string) (bool, error)
+
+type ListSetupExperienceResultsByHostUUIDFunc func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error)
+
+type UpdateSetupExperienceStatusResultFunc func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error
+
+type EnqueueSetupExperienceItemsFunc func(ctx context.Context, hostUUID string, teamID uint) (bool, error)
+
+type GetSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) (*fleet.Script, error)
+
+type SetSetupExperienceScriptFunc func(ctx context.Context, script *fleet.Script) error
+
+type DeleteSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) error
+
+type MaybeUpdateSetupExperienceScriptStatusFunc func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error)
+
+type MaybeUpdateSetupExperienceSoftwareInstallStatusFunc func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error)
+
+type MaybeUpdateSetupExperienceVPPStatusFunc func(ctx context.Context, hostUUID string, commandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error)
+
type ListAvailableFleetMaintainedAppsFunc func(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error)
type GetMaintainedAppByIDFunc func(ctx context.Context, appID uint) (*fleet.MaintainedApp, error)
@@ -2734,6 +2760,45 @@ type DataStore struct {
GetVPPTokenByLocationFunc GetVPPTokenByLocationFunc
GetVPPTokenByLocationFuncInvoked bool
+ SetSetupExperienceSoftwareTitlesFunc SetSetupExperienceSoftwareTitlesFunc
+ SetSetupExperienceSoftwareTitlesFuncInvoked bool
+
+ ListSetupExperienceSoftwareTitlesFunc ListSetupExperienceSoftwareTitlesFunc
+ ListSetupExperienceSoftwareTitlesFuncInvoked bool
+
+ SetHostAwaitingConfigurationFunc SetHostAwaitingConfigurationFunc
+ SetHostAwaitingConfigurationFuncInvoked bool
+
+ GetHostAwaitingConfigurationFunc GetHostAwaitingConfigurationFunc
+ GetHostAwaitingConfigurationFuncInvoked bool
+
+ ListSetupExperienceResultsByHostUUIDFunc ListSetupExperienceResultsByHostUUIDFunc
+ ListSetupExperienceResultsByHostUUIDFuncInvoked bool
+
+ UpdateSetupExperienceStatusResultFunc UpdateSetupExperienceStatusResultFunc
+ UpdateSetupExperienceStatusResultFuncInvoked bool
+
+ EnqueueSetupExperienceItemsFunc EnqueueSetupExperienceItemsFunc
+ EnqueueSetupExperienceItemsFuncInvoked bool
+
+ GetSetupExperienceScriptFunc GetSetupExperienceScriptFunc
+ GetSetupExperienceScriptFuncInvoked bool
+
+ SetSetupExperienceScriptFunc SetSetupExperienceScriptFunc
+ SetSetupExperienceScriptFuncInvoked bool
+
+ DeleteSetupExperienceScriptFunc DeleteSetupExperienceScriptFunc
+ DeleteSetupExperienceScriptFuncInvoked bool
+
+ MaybeUpdateSetupExperienceScriptStatusFunc MaybeUpdateSetupExperienceScriptStatusFunc
+ MaybeUpdateSetupExperienceScriptStatusFuncInvoked bool
+
+ MaybeUpdateSetupExperienceSoftwareInstallStatusFunc MaybeUpdateSetupExperienceSoftwareInstallStatusFunc
+ MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked bool
+
+ MaybeUpdateSetupExperienceVPPStatusFunc MaybeUpdateSetupExperienceVPPStatusFunc
+ MaybeUpdateSetupExperienceVPPStatusFuncInvoked bool
+
ListAvailableFleetMaintainedAppsFunc ListAvailableFleetMaintainedAppsFunc
ListAvailableFleetMaintainedAppsFuncInvoked bool
@@ -6535,6 +6600,97 @@ func (s *DataStore) GetVPPTokenByLocation(ctx context.Context, loc string) (*fle
return s.GetVPPTokenByLocationFunc(ctx, loc)
}
+func (s *DataStore) SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error {
+ s.mu.Lock()
+ s.SetSetupExperienceSoftwareTitlesFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetSetupExperienceSoftwareTitlesFunc(ctx, teamID, titleIDs)
+}
+
+func (s *DataStore) ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
+ s.mu.Lock()
+ s.ListSetupExperienceSoftwareTitlesFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListSetupExperienceSoftwareTitlesFunc(ctx, teamID, opts)
+}
+
+func (s *DataStore) SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, inSetupExperience bool) error {
+ s.mu.Lock()
+ s.SetHostAwaitingConfigurationFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetHostAwaitingConfigurationFunc(ctx, hostUUID, inSetupExperience)
+}
+
+func (s *DataStore) GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error) {
+ s.mu.Lock()
+ s.GetHostAwaitingConfigurationFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetHostAwaitingConfigurationFunc(ctx, hostUUID)
+}
+
+func (s *DataStore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) {
+ s.mu.Lock()
+ s.ListSetupExperienceResultsByHostUUIDFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListSetupExperienceResultsByHostUUIDFunc(ctx, hostUUID)
+}
+
+func (s *DataStore) UpdateSetupExperienceStatusResult(ctx context.Context, status *fleet.SetupExperienceStatusResult) error {
+ s.mu.Lock()
+ s.UpdateSetupExperienceStatusResultFuncInvoked = true
+ s.mu.Unlock()
+ return s.UpdateSetupExperienceStatusResultFunc(ctx, status)
+}
+
+func (s *DataStore) EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) {
+ s.mu.Lock()
+ s.EnqueueSetupExperienceItemsFuncInvoked = true
+ s.mu.Unlock()
+ return s.EnqueueSetupExperienceItemsFunc(ctx, hostUUID, teamID)
+}
+
+func (s *DataStore) GetSetupExperienceScript(ctx context.Context, teamID *uint) (*fleet.Script, error) {
+ s.mu.Lock()
+ s.GetSetupExperienceScriptFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetSetupExperienceScriptFunc(ctx, teamID)
+}
+
+func (s *DataStore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error {
+ s.mu.Lock()
+ s.SetSetupExperienceScriptFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetSetupExperienceScriptFunc(ctx, script)
+}
+
+func (s *DataStore) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
+ s.mu.Lock()
+ s.DeleteSetupExperienceScriptFuncInvoked = true
+ s.mu.Unlock()
+ return s.DeleteSetupExperienceScriptFunc(ctx, teamID)
+}
+
+func (s *DataStore) MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ s.mu.Lock()
+ s.MaybeUpdateSetupExperienceScriptStatusFuncInvoked = true
+ s.mu.Unlock()
+ return s.MaybeUpdateSetupExperienceScriptStatusFunc(ctx, hostUUID, executionID, status)
+}
+
+func (s *DataStore) MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ s.mu.Lock()
+ s.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = true
+ s.mu.Unlock()
+ return s.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc(ctx, hostUUID, executionID, status)
+}
+
+func (s *DataStore) MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, commandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ s.mu.Lock()
+ s.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = true
+ s.mu.Unlock()
+ return s.MaybeUpdateSetupExperienceVPPStatusFunc(ctx, hostUUID, commandUUID, status)
+}
+
func (s *DataStore) ListAvailableFleetMaintainedApps(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListAvailableFleetMaintainedAppsFuncInvoked = true
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index 44d81c756671..87081047c61e 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -908,8 +908,14 @@ func TestMDMAppleConfig(t *testing.T) {
name: "nochange",
licenseTier: "free",
expectedMDM: fleet.MDM{
- 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)},
+ 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),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
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}},
@@ -943,12 +949,18 @@ func TestMDMAppleConfig(t *testing.T) {
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}},
+ MacOSSetup: fleet.MacOSSetup{
+ BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
+ 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{}},
},
@@ -962,12 +974,18 @@ func TestMDMAppleConfig(t *testing.T) {
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}},
+ MacOSSetup: fleet.MacOSSetup{
+ BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
+ 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{}},
},
@@ -985,9 +1003,15 @@ 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{
- 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)},
+ 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),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
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}},
@@ -1015,7 +1039,13 @@ func TestMDMAppleConfig(t *testing.T) {
MetadataURL: "http://isser.metadata.com",
IDPName: "onelogin",
}},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
+ MacOSSetup: fleet.MacOSSetup{
+ BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
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}},
@@ -1075,9 +1105,15 @@ func TestMDMAppleConfig(t *testing.T) {
EnableDiskEncryption: optjson.SetBool(false),
},
expectedMDM: fleet.MDM{
- 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)},
+ 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),
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
+ Script: optjson.String{Set: true},
+ },
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}},
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 7115c06ce0f8..9567bd97e301 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -2751,6 +2751,15 @@ func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.
return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs")
}
+ if m.AwaitingConfiguration {
+ // Enqueue setup experience items and mark the host as being in setup experience
+ _, err := svc.ds.EnqueueSetupExperienceItems(r.Context, r.ID, info.TeamID)
+ if err != nil {
+ return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks")
+ }
+
+ }
+
return svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionTurnOn,
Platform: info.Platform,
@@ -2895,7 +2904,20 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.UDID, status, detail)
return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack")
case "InstallApplication":
- // Create an activity for installing only if we're in a terminal state
+ // this might be a setup experience VPP install, so we'll try to update setup experience status
+ // TODO: consider limiting this to only macOS hosts
+ if updated, err := maybeUpdateSetupExperienceStatus(r.Context, svc.ds, fleet.SetupExperienceVPPInstallResult{
+ HostUUID: cmdResult.UDID,
+ CommandUUID: cmdResult.CommandUUID,
+ CommandStatus: cmdResult.Status,
+ }, true); err != nil {
+ return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result")
+ } else if updated {
+ // TODO: call next step of setup experience?
+ level.Debug(svc.logger).Log("msg", "setup experience script result updated", "host_uuid", cmdResult.UDID, "execution_id", cmdResult.CommandUUID)
+ }
+
+ // create an activity for installing only if we're in a terminal state
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
cmdResult.Status == fleet.MDMAppleStatusError ||
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
@@ -2913,6 +2935,10 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app")
}
}
+ case "DeviceConfigured":
+ if err := svc.ds.SetHostAwaitingConfiguration(r.Context, r.ID, false); err != nil {
+ return nil, ctxerr.Wrap(r.Context, err, "failed to mark host as non longer awaiting configuration")
+ }
}
return nil, nil
diff --git a/server/service/client.go b/server/service/client.go
index db0fdf8c2612..dd975b9567a5 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -389,9 +389,32 @@ func getProfilesContents(baseDir string, macProfiles []fleet.MDMProfileSpec, win
return result, nil
}
+// fileContent is used to store the name of a file and its content.
+type fileContent struct {
+ Filename string
+ Content []byte
+}
+
+// TODO: as confirmed by Noah and Marko on Slack:
+//
+// > from Noah: "We want to support existing features w/ fleetctl apply for
+// > backwards compatibility GitOps but we don’t need to add new features."
+//
+// We should deprecate ApplyGroup and use it only for `fleetctl apply` (and
+// its current minimal use in `preview`), and have a distinct implementation
+// that is `gitops`-only, because both uses have subtle differences in
+// behaviour that make it hard to reuse a single implementation (e.g. a missing
+// key in gitops means "remove what is absent" while in apply it means "leave
+// as-is").
+//
+// For now I'm just passing a "gitops" bool for a quick fix, but we should
+// properly plan that separation and refactor so that gitops can be
+// significantly cleaned up and simplified going forward.
+
// ApplyGroup applies the given spec group to Fleet.
func (c *Client) ApplyGroup(
ctx context.Context,
+ viaGitOps bool,
specs *spec.Group,
baseDir string,
logf func(format string, args ...interface{}),
@@ -553,6 +576,15 @@ func (c *Client) ApplyGroup(
tmMacSetup := extractTmSpecsMacOSSetup(specs.Teams)
tmBootstrapPackages := make(map[string]*fleet.MDMAppleBootstrapPackage, len(tmMacSetup))
tmMacSetupAssistants := make(map[string][]byte, len(tmMacSetup))
+
+ // those are gitops-only features
+ tmMacSetupScript := make(map[string]fileContent, len(tmMacSetup))
+ tmMacSetupSoftware := make(map[string][]*fleet.MacOSSetupSoftware, len(tmMacSetup))
+ // this is a set of software packages or VPP apps that are configured as
+ // install_during_setup, by team. This is a gitops-only setting, so it will
+ // only be filled when called via this command.
+ tmSoftwareMacOSSetup := make(map[string]map[fleet.MacOSSetupSoftware]struct{}, len(tmMacSetup))
+
for k, setup := range tmMacSetup {
if setup.BootstrapPackage.Value != "" {
bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value)
@@ -568,6 +600,21 @@ func (c *Client) ApplyGroup(
}
tmMacSetupAssistants[k] = b
}
+ if setup.Script.Value != "" {
+ b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, setup.Script.Value))
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("applying teams: %w", err)
+ }
+ tmMacSetupScript[k] = fileContent{Filename: filepath.Base(setup.Script.Value), Content: b}
+ }
+ if viaGitOps {
+ m, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, setup.Software.Value)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ tmSoftwareMacOSSetup[k] = m
+ tmMacSetupSoftware[k] = setup.Software.Value
+ }
}
tmScripts := extractTmSpecsScripts(specs.Teams)
@@ -589,24 +636,59 @@ func (c *Client) ApplyGroup(
tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams)
tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmSoftwarePackages))
+ tmSoftwarePackageByPath := make(map[string]map[string]fleet.SoftwarePackageSpec, len(tmSoftwarePackages))
for tmName, software := range tmSoftwarePackages {
- softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software)
+ installDuringSetupKeys := tmSoftwareMacOSSetup[tmName]
+ softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software, installDuringSetupKeys)
if err != nil {
return nil, nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err)
}
tmSoftwarePackagesPayloads[tmName] = softwarePayloads
+ for _, swSpec := range software {
+ if swSpec.ReferencedYamlPath != "" {
+ // can be referenced by macos_setup.software.package_path
+ if tmSoftwarePackageByPath[tmName] == nil {
+ tmSoftwarePackageByPath[tmName] = make(map[string]fleet.SoftwarePackageSpec, len(software))
+ }
+ tmSoftwarePackageByPath[tmName][swSpec.ReferencedYamlPath] = swSpec
+ }
+ }
}
tmSoftwareApps := extractTmSpecsSoftwareApps(specs.Teams)
tmSoftwareAppsPayloads := make(map[string][]fleet.VPPBatchPayload)
+ tmSoftwareAppsByAppID := make(map[string]map[string]fleet.TeamSpecAppStoreApp, len(tmSoftwareApps))
for tmName, apps := range tmSoftwareApps {
+ installDuringSetupKeys := tmSoftwareMacOSSetup[tmName]
appPayloads := make([]fleet.VPPBatchPayload, 0, len(apps))
for _, app := range apps {
- appPayloads = append(appPayloads, fleet.VPPBatchPayload{AppStoreID: app.AppStoreID, SelfService: app.SelfService})
+ var installDuringSetup *bool
+ if installDuringSetupKeys != nil {
+ _, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{AppStoreID: app.AppStoreID}]
+ installDuringSetup = &ok
+ }
+ appPayloads = append(appPayloads, fleet.VPPBatchPayload{
+ AppStoreID: app.AppStoreID,
+ SelfService: app.SelfService,
+ InstallDuringSetup: installDuringSetup,
+ })
+ // can be referenced by macos_setup.software.app_store_id
+ if tmSoftwareAppsByAppID[tmName] == nil {
+ tmSoftwareAppsByAppID[tmName] = make(map[string]fleet.TeamSpecAppStoreApp, len(apps))
+ }
+ tmSoftwareAppsByAppID[tmName][app.AppStoreID] = app
}
tmSoftwareAppsPayloads[tmName] = appPayloads
}
+ // if macos_setup.software has some values, they must exist in the software
+ // packages or vpp apps.
+ for tmName, setupSw := range tmMacSetupSoftware {
+ if err := validateTeamOrNoTeamMacOSSetupSoftware(tmName, setupSw, tmSoftwarePackageByPath[tmName], tmSoftwareAppsByAppID[tmName]); err != nil {
+ return nil, nil, nil, err
+ }
+ }
+
// Next, apply the teams specs before saving the profiles, so that any
// non-existing team gets created.
var err error
@@ -674,6 +756,19 @@ func (c *Client) ApplyGroup(
}
}
}
+ if viaGitOps && !opts.DryRun {
+ for tmName, tmID := range teamIDsByName {
+ if fc, ok := tmMacSetupScript[tmName]; ok {
+ if err := c.uploadMacOSSetupScript(fc.Filename, fc.Content, &tmID); err != nil {
+ return nil, nil, nil, fmt.Errorf("uploading setup experience script for team %q: %w", tmName, err)
+ }
+ } else {
+ if err := c.deleteMacOSSetupScript(&tmID); err != nil {
+ return nil, nil, nil, fmt.Errorf("deleting setup experience script for team %q: %w", tmName, err)
+ }
+ }
+ }
+ }
if len(tmScriptsPayloads) > 0 {
for tmName, scripts := range tmScriptsPayloads {
// For non-dry run, currentTeamName and tmName are the same
@@ -701,6 +796,7 @@ func (c *Client) ApplyGroup(
for tmName, apps := range tmSoftwareAppsPayloads {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
+ logfn("[+] applying %d app store apps for team %s\n", len(apps), tmName)
if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil {
return nil, nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err)
}
@@ -751,7 +847,45 @@ func (c *Client) ApplyGroup(
return teamIDsByName, teamsSoftwareInstallers, teamsScripts, nil
}
-func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) {
+func extractTeamOrNoTeamMacOSSetupSoftware(baseDir string, software []*fleet.MacOSSetupSoftware) (map[fleet.MacOSSetupSoftware]struct{}, error) {
+ m := make(map[fleet.MacOSSetupSoftware]struct{}, len(software))
+ for _, sw := range software {
+ if sw.AppStoreID != "" && sw.PackagePath != "" {
+ return nil, errors.New("applying teams: only one of app_store_id or package_path can be set")
+ }
+ if sw.PackagePath != "" {
+ sw.PackagePath = resolveApplyRelativePath(baseDir, sw.PackagePath)
+ }
+ m[*sw] = struct{}{}
+ }
+ return m, nil
+}
+
+func validateTeamOrNoTeamMacOSSetupSoftware(teamName string, macOSSetupSoftware []*fleet.MacOSSetupSoftware, packagesByPath map[string]fleet.SoftwarePackageSpec, vppAppsByAppID map[string]fleet.TeamSpecAppStoreApp) error {
+ // if macos_setup.software has some values, they must exist in the software
+ // packages or vpp apps.
+ for _, ssw := range macOSSetupSoftware {
+ var valid bool
+ if ssw.AppStoreID != "" {
+ // check that it exists in the team's Apps
+ _, valid = vppAppsByAppID[ssw.AppStoreID]
+ } else if ssw.PackagePath != "" {
+ // check that it exists in the team's Software installers (PackagePath is
+ // already resolved to abs dir)
+ _, valid = packagesByPath[ssw.PackagePath]
+ }
+ if !valid {
+ label := ssw.AppStoreID
+ if label == "" {
+ label = ssw.PackagePath
+ }
+ return fmt.Errorf("applying macOS setup experience software for team %q: software %q does not exist for that team", teamName, label)
+ }
+ }
+ return nil
+}
+
+func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec, installDuringSetupKeys map[fleet.MacOSSetupSoftware]struct{}) ([]fleet.SoftwareInstallerPayload, error) {
softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs))
for i, si := range specs {
var qc string
@@ -837,15 +971,20 @@ func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageS
}
}
+ var installDuringSetup *bool
+ if installDuringSetupKeys != nil {
+ _, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{PackagePath: si.ReferencedYamlPath}]
+ installDuringSetup = &ok
+ }
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
- URL: si.URL,
- SelfService: si.SelfService,
- PreInstallQuery: qc,
- InstallScript: string(ic),
- PostInstallScript: string(pc),
- UninstallScript: string(us),
+ URL: si.URL,
+ SelfService: si.SelfService,
+ PreInstallQuery: qc,
+ InstallScript: string(ic),
+ PostInstallScript: string(pc),
+ UninstallScript: string(us),
+ InstallDuringSetup: installDuringSetup,
}
-
}
return softwarePayloads, nil
@@ -1482,7 +1621,7 @@ func (c *Client) DoGitOps(
}
// Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls.
- teamIDsByName, teamsSoftwareInstallers, teamsScripts, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{
+ teamIDsByName, teamsSoftwareInstallers, teamsScripts, err := c.ApplyGroup(ctx, true, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{
ApplySpecOptions: fleet.ApplySpecOptions{
DryRun: dryRun,
},
@@ -1539,30 +1678,89 @@ func (c *Client) DoGitOps(
return teamAssumptions, nil
}
-func (c *Client) doGitOpsNoTeamSoftware(config *spec.GitOps, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwarePackageResponse, error) {
+func (c *Client) doGitOpsNoTeamSoftware(
+ config *spec.GitOps,
+ baseDir string,
+ appconfig *fleet.EnrichedAppConfig,
+ logFn func(format string, args ...interface{}),
+ dryRun bool,
+) ([]fleet.SoftwarePackageResponse, error) {
+ if !config.IsNoTeam() || appconfig == nil || !appconfig.License.IsPremium() {
+ return nil, nil
+ }
+
var softwareInstallers []fleet.SoftwarePackageResponse
- if config.IsNoTeam() && appconfig != nil && appconfig.License.IsPremium() {
- packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages))
- for _, software := range config.Software.Packages {
- if software != nil {
- packages = append(packages, *software)
+
+ packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages))
+ packagesByPath := make(map[string]fleet.SoftwarePackageSpec, len(config.Software.Packages))
+ for _, software := range config.Software.Packages {
+ if software != nil {
+ packages = append(packages, *software)
+ if software.ReferencedYamlPath != "" {
+ // can be referenced by macos_setup.software
+ packagesByPath[software.ReferencedYamlPath] = *software
}
}
- payload, err := buildSoftwarePackagesPayload(baseDir, packages)
- if err != nil {
- return nil, fmt.Errorf("applying software installers: %w", err)
- }
- logFn("[+] applying %d software packages for 'No team'\n", len(payload))
- softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun})
+ }
+
+ // marshaling dance to get the macos_setup data - config.Controls.MacOSSetup
+ // is of type any and contains a generic map[string]any. By
+ // marshal-unmarshaling it into a properly typed struct, we avoid having to
+ // do a bunch of error-prone and unmaintainable type-assertions to walk down
+ // the untyped map.
+ b, err := json.Marshal(config.Controls.MacOSSetup)
+ if err != nil {
+ return nil, fmt.Errorf("applying software installers: json-encode controls.macos_setup: %w", err)
+ }
+ var macOSSetup fleet.MacOSSetup
+ if err := json.Unmarshal(b, &macOSSetup); err != nil {
+ return nil, fmt.Errorf("applying software installers: json-decode controls.macos_setup: %w", err)
+ }
+
+ // load the no-team macos_setup.script if any
+ var macosSetupScript *fileContent
+ if macOSSetup.Script.Value != "" {
+ b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, macOSSetup.Script.Value))
if err != nil {
- return nil, fmt.Errorf("applying software installers: %w", err)
+ return nil, fmt.Errorf("applying no team macos_setup.script: %w", err)
}
+ macosSetupScript = &fileContent{Filename: filepath.Base(macOSSetup.Script.Value), Content: b}
+ }
- if dryRun {
- logFn("[+] would've applied 'No Team' software packages\n")
- } else {
- logFn("[+] applied 'No Team' software packages\n")
+ noTeamSoftwareMacOSSetup, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, macOSSetup.Software.Value)
+ if err != nil {
+ return nil, err
+ }
+
+ // TODO: note that VPP apps are not validated nor taken into account at the moment,
+ // tracked with issue https://github.com/fleetdm/fleet/issues/22970
+ if err := validateTeamOrNoTeamMacOSSetupSoftware(*config.TeamName, macOSSetup.Software.Value, packagesByPath, nil); err != nil {
+ return nil, err
+ }
+ payload, err := buildSoftwarePackagesPayload(baseDir, packages, noTeamSoftwareMacOSSetup)
+ if err != nil {
+ return nil, fmt.Errorf("applying software installers: %w", err)
+ }
+
+ if macosSetupScript != nil {
+ logFn("[+] applying macos setup experience script for 'No team'\n")
+ if err := c.uploadMacOSSetupScript(macosSetupScript.Filename, macosSetupScript.Content, nil); err != nil {
+ return nil, fmt.Errorf("uploading setup experience script for No team: %w", err)
}
+ } else if err := c.deleteMacOSSetupScript(nil); err != nil {
+ return nil, fmt.Errorf("deleting setup experience script for No team: %w", err)
+ }
+
+ logFn("[+] applying %d software packages for 'No team'\n", len(payload))
+ softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun})
+ if err != nil {
+ return nil, fmt.Errorf("applying software installers: %w", err)
+ }
+
+ if dryRun {
+ logFn("[+] would've applied 'No Team' software packages\n")
+ } else {
+ logFn("[+] applied 'No Team' software packages\n")
}
return softwareInstallers, nil
}
diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go
index 7a47c162e908..9761fe24b214 100644
--- a/server/service/client_scripts.go
+++ b/server/service/client_scripts.go
@@ -1,11 +1,15 @@
package service
import (
+ "bytes"
+ "context"
"encoding/json"
"errors"
"fmt"
"io"
+ "mime/multipart"
"net/http"
+ "os"
"strings"
"time"
@@ -139,3 +143,78 @@ func (c *Client) ApplyNoTeamScripts(scripts []fleet.ScriptPayload, opts fleet.Ap
return resp.Scripts, err
}
+
+func (c *Client) validateMacOSSetupScript(fileName string) ([]byte, error) {
+ if err := c.CheckAppleMDMEnabled(); err != nil {
+ return nil, err
+ }
+
+ b, err := os.ReadFile(fileName)
+ if err != nil {
+ return nil, err
+ }
+ return b, nil
+}
+
+func (c *Client) deleteMacOSSetupScript(teamID *uint) error {
+ var query string
+ if teamID != nil {
+ query = fmt.Sprintf("team_id=%d", *teamID)
+ }
+
+ verb, path := "DELETE", "/api/latest/fleet/setup_experience/script"
+ var delResp deleteSetupExperienceScriptResponse
+ return c.authenticatedRequestWithQuery(nil, verb, path, &delResp, query)
+}
+
+func (c *Client) uploadMacOSSetupScript(filename string, data []byte, teamID *uint) error {
+ // there is no "replace setup experience script" endpoint, and none was
+ // planned, so to avoid delaying the feature I'm doing DELETE then SET, but
+ // that's not ideal (will always re-create the script when apply/gitops is
+ // run with the same yaml). Note though that we also redo software installers
+ // downloads on each run, so the churn of this one is minor in comparison.
+ if err := c.deleteMacOSSetupScript(teamID); err != nil {
+ return err
+ }
+
+ verb, path := "POST", "/api/latest/fleet/setup_experience/script"
+
+ var b bytes.Buffer
+ w := multipart.NewWriter(&b)
+
+ fw, err := w.CreateFormFile("script", filename)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(fw, bytes.NewBuffer(data)); err != nil {
+ return err
+ }
+
+ // add the team_id field
+ if teamID != nil {
+ if err := w.WriteField("team_id", fmt.Sprint(*teamID)); err != nil {
+ return err
+ }
+ }
+ w.Close()
+
+ response, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path, "",
+ b.Bytes(),
+ map[string]string{
+ "Content-Type": w.FormDataContentType(),
+ "Accept": "application/json",
+ "Authorization": fmt.Sprintf("Bearer %s", c.token),
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("do multipart request: %w", err)
+ }
+ defer response.Body.Close()
+
+ var resp setSetupExperienceScriptResponse
+ if err := c.parseResponse(verb, path, response, &resp); err != nil {
+ return fmt.Errorf("parse response: %w", err)
+ }
+
+ return nil
+}
diff --git a/server/service/handler.go b/server/service/handler.go
index 19135d7bbce0..56cc734eca0f 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -391,6 +391,13 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/software/app_store_apps", getAppStoreAppsEndpoint, getAppStoreAppsRequest{})
ue.POST("/api/_version_/fleet/software/app_store_apps", addAppStoreAppEndpoint, addAppStoreAppRequest{})
+ // Setup Experience
+ ue.PUT("/api/_version_/fleet/setup_experience/software", putSetupExperienceSoftware, putSetupExperienceSoftwareRequest{})
+ ue.GET("/api/_version_/fleet/setup_experience/software", getSetupExperienceSoftware, getSetupExperienceSoftwareRequest{})
+ ue.GET("/api/_version_/fleet/setup_experience/script", getSetupExperienceScriptEndpoint, getSetupExperienceScriptRequest{})
+ ue.POST("/api/_version_/fleet/setup_experience/script", setSetupExperienceScriptEndpoint, setSetupExperienceScriptRequest{})
+ ue.DELETE("/api/_version_/fleet/setup_experience/script", deleteSetupExperienceScriptEndpoint, deleteSetupExperienceScriptRequest{})
+
// Fleet-maintained apps
ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{})
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{})
@@ -859,11 +866,12 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
oe.POST("/api/fleet/orbit/scripts/result", postOrbitScriptResultEndpoint, orbitPostScriptResultRequest{})
oe.PUT("/api/fleet/orbit/device_mapping", putOrbitDeviceMappingEndpoint, orbitPutDeviceMappingRequest{})
oe.POST("/api/fleet/orbit/software_install/result", postOrbitSoftwareInstallResultEndpoint, orbitPostSoftwareInstallResultRequest{})
-
oe.POST("/api/fleet/orbit/software_install/package", orbitDownloadSoftwareInstallerEndpoint, orbitDownloadSoftwareInstallerRequest{})
-
oe.POST("/api/fleet/orbit/software_install/details", getOrbitSoftwareInstallDetails, orbitGetSoftwareInstallRequest{})
+ oeAppleMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())
+ oeAppleMDM.POST("/api/fleet/orbit/setup_experience/status", getOrbitSetupExperienceStatusEndpoint, getOrbitSetupExperienceStatusRequest{})
+
oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM())
oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{})
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 801fc2086775..a8106dd270bf 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -12,7 +12,6 @@ import (
"errors"
"fmt"
"io"
- "mime/multipart"
"net/http"
"net/http/httptest"
"os"
@@ -236,6 +235,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
// because the WindowsSettings was marshalled to JSON to be saved in the DB,
// it did get marshalled, and then when unmarshalled it was set (but
@@ -338,6 +339,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -368,6 +371,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -400,6 +405,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -2366,6 +2373,8 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() {
MacOSSetupAssistant: optjson.String{Set: true},
BootstrapPackage: optjson.String{Set: true},
EnableReleaseDeviceManually: optjson.SetBool(false),
+ Script: optjson.String{Set: true},
+ Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -8596,14 +8605,14 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
SelfService: false,
TeamID: &team1.ID,
}
- s.uploadSoftwareInstaller(payloadRubyTm1, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "")
payloadEmacs := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "emacs.deb",
SelfService: true,
}
- s.uploadSoftwareInstaller(payloadEmacs, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadEmacs, http.StatusOK, "")
payloadVim := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
@@ -8611,7 +8620,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
SelfService: true,
TeamID: ptr.Uint(0),
}
- s.uploadSoftwareInstaller(payloadVim, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadVim, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
@@ -8631,7 +8640,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
Filename: "ruby_arm64.deb",
TeamID: &team2.ID,
}
- s.uploadSoftwareInstaller(payloadRubyTm2, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadRubyTm2, http.StatusOK, "")
// We should only see the one we uploaded to team 1
resp = listSoftwareTitlesResponse{}
@@ -10056,7 +10065,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
Filename: "ruby.deb",
Version: "1:2.5.1",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages")
// update it to be self-service
@@ -10198,7 +10207,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
Filename: "dummy_installer.pkg",
Version: "0.0.2", // The version can be anything -- we match on title
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// Get software available for install
getHostSw = getHostSoftwareResponse{}
@@ -10320,7 +10329,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
Platform: "linux",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false}`, 0)
@@ -10329,7 +10338,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
_, titleID := checkSoftwareInstaller(t, payload)
// upload again fails
- s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists")
+ s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// orbit-downloading fails with invalid orbit node key
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
@@ -10368,7 +10377,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
Platform: "linux",
SelfService: true,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check the software installer
installerID, titleID := checkSoftwareInstaller(t, payload)
@@ -10377,7 +10386,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0)
// upload again fails
- s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists")
+ s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// download the installer
r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID))
@@ -10483,7 +10492,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
Platform: "linux",
SelfService: true,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
// check the software installer
installerID, titleID := checkSoftwareInstaller(t, payload)
@@ -10492,7 +10501,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0)
// upload again fails
- s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists")
+ s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// download the installer
r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0))
@@ -10578,7 +10587,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
logger := kitlog.NewLogfmtLogger(os.Stderr)
@@ -11592,7 +11601,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
Title: "ruby",
TeamID: teamID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
// Get title with software installer
@@ -11609,7 +11618,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
Title: "DummyApp.app",
TeamID: teamID,
}
- s.uploadSoftwareInstaller(payloadDummy, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
pkgTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", pkgTitleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", *teamID))
@@ -11964,7 +11973,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
Title: "ruby",
SelfService: false,
}
- s.uploadSoftwareInstaller(payloadNoSS, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadNoSS, http.StatusOK, "")
titleIDNoSS := getSoftwareTitleID(t, s.ds, payloadNoSS.Title, "deb_packages")
payloadSS := &fleet.UploadSoftwareInstallerPayload{
@@ -11975,7 +11984,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
Title: "emacs",
SelfService: true,
}
- s.uploadSoftwareInstaller(payloadSS, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
// cannot self-install if software installer does not allow it
@@ -12045,7 +12054,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
Filename: "ruby.deb",
Title: "ruby",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
payload2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script 2",
@@ -12054,7 +12063,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
Filename: "vim.deb",
Title: "vim",
}
- s.uploadSoftwareInstaller(payload2, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload2, http.StatusOK, "")
titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages")
payload3 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script 3",
@@ -12063,7 +12072,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
Filename: "emacs.deb",
Title: "emacs",
}
- s.uploadSoftwareInstaller(payload3, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload3, http.StatusOK, "")
titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages")
latestInstallUUID := func() string {
@@ -12299,64 +12308,6 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
require.EqualValues(t, 0, *scriptRes.ExitCode)
}
-func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(
- payload *fleet.UploadSoftwareInstallerPayload,
- expectedStatus int,
- expectedError string,
-) {
- t := s.T()
- t.Helper()
- openFile := func(name string) *os.File {
- f, err := os.Open(filepath.Join("testdata", "software-installers", name))
- require.NoError(t, err)
- return f
- }
-
- f := openFile(payload.Filename)
- defer f.Close()
-
- payload.InstallerFile = f
-
- var b bytes.Buffer
- w := multipart.NewWriter(&b)
-
- // add the software field
- fw, err := w.CreateFormFile("software", payload.Filename)
- require.NoError(t, err)
- n, err := io.Copy(fw, payload.InstallerFile)
- require.NoError(t, err)
- require.NotZero(t, n)
-
- // add the team_id field
- if payload.TeamID != nil {
- require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID)))
- }
- // add the remaining fields
- require.NoError(t, w.WriteField("install_script", payload.InstallScript))
- require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery))
- require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript))
- require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript))
- if payload.SelfService {
- require.NoError(t, w.WriteField("self_service", "true"))
- }
-
- w.Close()
-
- headers := map[string]string{
- "Content-Type": w.FormDataContentType(),
- "Accept": "application/json",
- "Authorization": fmt.Sprintf("Bearer %s", s.token),
- }
-
- 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)
- }
-}
-
func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) uint {
var id uint
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -12582,7 +12533,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() {
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp := listSoftwareTitlesResponse{}
s.DoJSON(
@@ -12686,7 +12637,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() {
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.")
+ s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.")
}
// 1. host reports software
@@ -12754,7 +12705,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() {
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
@@ -12818,7 +12769,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() {
Filename: "dummy_installer.pkg",
TeamID: &team.ID,
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
resp := listSoftwareTitlesResponse{}
s.DoJSON(
@@ -13727,7 +13678,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
Filename: "dummy_installer.pkg",
TeamID: &team1.ID,
}
- s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
@@ -13790,7 +13741,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
s.token = adminToken
})
s.token = adminTeam1Session.Key
- s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
s.token = adminToken
err = s.ds.DeleteUser(ctx, adminTeam1.ID)
require.NoError(t, err)
@@ -13835,7 +13786,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
// author (the admin that uploaded the installer).
SelfService: true,
}
- s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, fleetOsqueryPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp = listSoftwareTitlesResponse{}
s.DoJSON(
@@ -14852,7 +14803,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIden
Filename: "dummy_installer.pkg",
Version: "0.0.2",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
}
func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() {
@@ -14870,7 +14821,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() {
Filename: "ruby.rpm",
Title: "ruby",
}
- s.uploadSoftwareInstaller(payload, http.StatusOK, "")
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "rpm_packages")
latestInstallUUID := func() string {
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
index fd37517c4677..670d1de5dbe8 100644
--- a/server/service/integration_mdm_dep_test.go
+++ b/server/service/integration_mdm_dep_test.go
@@ -271,10 +271,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, device.SerialNumber)
+ enrolledHost := listHostsRes.Hosts[0].Host
t.Cleanup(func() {
// delete the enrolled host
- err := s.ds.DeleteHost(ctx, listHostsRes.Hosts[0].ID)
+ err := s.ds.DeleteHost(ctx, enrolledHost.ID)
require.NoError(t, err)
})
@@ -353,6 +354,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
require.True(t, profileFleetCASeen)
require.True(t, profileFileVaultSeen)
+ // simulate fleetd being installed and the host being orbit-enrolled now
+ enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID)
+ orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds)
+ enrolledHost.OrbitNodeKey = &orbitKey
+
if enableReleaseManually {
// get the worker's pending job from the future, there should not be any
// because it needs to be released manually
@@ -360,12 +366,14 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
require.NoError(t, err)
require.Empty(t, pending)
} else {
- // there should be a Release Device pending job in the near future, expect
- // it and schedule it to run now.
- s.expectAndScheduleReleaseDeviceJob(t)
+ // there shouldn't be a Release Device pending job anymore
+ pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
+ require.NoError(t, err)
+ require.Len(t, pending, 0)
- // run the worker to process the DEP release
- s.runWorker()
+ // call the /status endpoint to automatically release the host
+ var statusResp getOrbitSetupExperienceStatusResponse
+ s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
// make the device process the commands, it should receive the
// DeviceConfigured one.
@@ -395,24 +403,6 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
}
}
-func (s *integrationMDMTestSuite) expectAndScheduleReleaseDeviceJob(t *testing.T) {
- ctx := context.Background()
-
- // get the worker's pending job from the future, there should be a DEP
- // release device task
- pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
- require.NoError(t, err)
- require.Len(t, pending, 1)
- releaseJob := pending[0]
- require.Equal(t, 0, releaseJob.Retries)
- require.Contains(t, string(*releaseJob.Args), worker.AppleMDMPostDEPReleaseDeviceTask)
-
- // update the job so that it can run immediately
- releaseJob.NotBefore = time.Now().UTC().Add(-time.Minute)
- _, err = s.ds.UpdateJob(ctx, releaseJob.ID, releaseJob)
- require.NoError(t, err)
-}
-
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
@@ -813,7 +803,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
require.NoError(t, err)
// send a TokenUpdate command, it shouldn't re-send the post-enrollment commands
- err = mdmDevice.TokenUpdate()
+ err = mdmDevice.TokenUpdate(false)
require.NoError(t, err)
checkPostEnrollmentCommands(mdmDevice, false)
@@ -1536,3 +1526,599 @@ func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() {
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam)
}
+
+func (s *integrationMDMTestSuite) TestSetupExperienceScript() {
+ t := s.T()
+
+ tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
+ Name: t.Name(),
+ Description: "desc",
+ })
+ require.NoError(t, err)
+
+ // create new team script
+ var newScriptResp setSetupExperienceScriptResponse
+ body, headers := generateNewScriptMultipartRequest(t,
+ "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
+ res := s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
+ err = json.NewDecoder(res.Body).Decode(&newScriptResp)
+ require.NoError(t, err)
+
+ // get team script metadata
+ var getScriptResp getSetupExperienceScriptResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK, &getScriptResp)
+ require.Equal(t, "script42.sh", getScriptResp.Name)
+ require.NotNil(t, getScriptResp.TeamID)
+ require.Equal(t, tm.ID, *getScriptResp.TeamID)
+ require.NotZero(t, getScriptResp.ID)
+ require.NotZero(t, getScriptResp.CreatedAt)
+ require.NotZero(t, getScriptResp.UpdatedAt)
+
+ // get team script contents
+ res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", tm.ID), nil, http.StatusOK)
+ b, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+ require.Equal(t, `echo "hello"`, string(b))
+ require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength)
+ require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition"))
+
+ // try to create script with same name, should fail because already exists with this name for this team
+ body, headers = generateNewScriptMultipartRequest(t,
+ "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusConflict, headers)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "already exists") // TODO: confirm expected error message with product/frontend
+
+ // try to create with a different name for this team, should fail because another script already exists
+ // for this team
+ body, headers = generateNewScriptMultipartRequest(t,
+ "different.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusConflict, headers)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "already exists") // TODO: confirm expected error message with product/frontend
+
+ // create no-team script
+ body, headers = generateNewScriptMultipartRequest(t,
+ "script42.sh", []byte(`echo "hello"`), s.token, nil)
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
+ err = json.NewDecoder(res.Body).Decode(&newScriptResp)
+ require.NoError(t, err)
+ // // TODO: confirm if we will allow team_id=0 requests
+ // noTeamID := uint(0) // TODO: confirm if we will allow team_id=0 requests
+ // body, headers = generateNewScriptMultipartRequest(t,
+ // "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", noTeamID)}})
+
+ // get no-team script metadata
+ s.DoJSON("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK, &getScriptResp)
+ require.Equal(t, "script42.sh", getScriptResp.Name)
+ require.Nil(t, getScriptResp.TeamID)
+ require.NotZero(t, getScriptResp.ID)
+ require.NotZero(t, getScriptResp.CreatedAt)
+ require.NotZero(t, getScriptResp.UpdatedAt)
+ // // TODO: confirm if we will allow team_id=0 requests
+ // s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", noTeamID), nil, http.StatusOK, &getScriptResp)
+
+ // get no-team script contents
+ res = s.Do("GET", "/api/latest/fleet/setup_experience/script?alt=media", nil, http.StatusOK)
+ b, err = io.ReadAll(res.Body)
+ require.NoError(t, err)
+ require.Equal(t, `echo "hello"`, string(b))
+ require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength)
+ require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition"))
+ // // TODO: confirm if we will allow team_id=0 requests
+ // res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", noTeamID), nil, http.StatusOK)
+
+ // delete the no-team script
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script"), nil, http.StatusOK)
+
+ // try get the no-team script
+ s.Do("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusNotFound)
+
+ // try deleting the no-team script again
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script"), nil, http.StatusOK) // TODO: confirm if we want to return not found
+
+ // // TODO: confirm if we will allow team_id=0 requests
+ // s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script/?team_id=%d", noTeamID), nil, http.StatusOK)
+
+ // delete the team script
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK)
+
+ // try get the team script
+ s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusNotFound)
+
+ // try deleting the team script again
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK) // TODO: confirm if we want to return not found
+}
+
+func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript() (device godep.Device, host *fleet.Host, tm *fleet.Team) {
+ t := s.T()
+ ctx := context.Background()
+
+ // enroll a device in a team with software to install and a script to execute
+ s.enableABM("fleet-setup-experience")
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
+ require.NoError(t, err)
+
+ teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
+
+ // add a team profile
+ teamProfile := mobileconfigForTest("N1", "I1")
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
+
+ // add a macOS software to install
+ payloadDummy := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ Filename: "dummy_installer.pkg",
+ Title: "DummyApp.app",
+ TeamID: &tm.ID,
+ }
+ s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "")
+ titleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
+ var swInstallResp putSetupExperienceSoftwareResponse
+ s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: tm.ID, TitleIDs: []uint{titleID}}, http.StatusOK, &swInstallResp)
+
+ // add a script to execute
+ body, headers := generateNewScriptMultipartRequest(t,
+ "script.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers)
+
+ // no bootstrap package, no custom setup assistant (those are already tested
+ // in the DEPEnrollReleaseDevice tests).
+
+ s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ return map[string]*push.Response{}, nil
+ }
+
+ s.mockDEPResponse("fleet-setup-experience", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ encoder := json.NewEncoder(w)
+ switch r.URL.Path {
+ case "/session":
+ err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
+ require.NoError(t, err)
+ case "/profile":
+ err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
+ require.NoError(t, err)
+ case "/server/devices":
+ err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}})
+ require.NoError(t, err)
+ case "/devices/sync":
+ // This endpoint is polled over time to sync devices from
+ // ABM, send a repeated serial
+ err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}, Cursor: "foo"})
+ require.NoError(t, err)
+ case "/profile/devices":
+ b, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+
+ var prof profileAssignmentReq
+ require.NoError(t, json.Unmarshal(b, &prof))
+
+ var resp godep.ProfileResponse
+ resp.ProfileUUID = prof.ProfileUUID
+ resp.Devices = make(map[string]string, len(prof.Devices))
+ for _, device := range prof.Devices {
+ resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
+ }
+ err = encoder.Encode(resp)
+ require.NoError(t, err)
+ default:
+ _, _ = w.Write([]byte(`{}`))
+ }
+ }))
+
+ // trigger a profile sync
+ s.runDEPSchedule()
+
+ // the (ghost) host now exists
+ listHostsRes := listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, 1)
+ require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, teamDevice.SerialNumber)
+ enrolledHost := listHostsRes.Hosts[0].Host
+
+ // transfer it to the team
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &tm.ID, HostIDs: []uint{enrolledHost.ID}}, http.StatusOK)
+
+ return teamDevice, enrolledHost, tm
+}
+
+func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAutoRelease() {
+ t := s.T()
+ ctx := context.Background()
+
+ teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript()
+
+ // enroll the host
+ depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
+ mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
+ mdmDevice.SerialNumber = teamDevice.SerialNumber
+ err := mdmDevice.Enroll()
+ require.NoError(t, err)
+
+ // run the worker to process the DEP enroll request
+ s.runWorker()
+ // run the worker to assign configuration profiles
+ s.awaitTriggerProfileSchedule(t)
+
+ var cmds []*micromdm.CommandPayload
+ cmd, err := mdmDevice.Idle()
+ require.NoError(t, err)
+ for cmd != nil {
+
+ var fullCmd micromdm.CommandPayload
+ require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
+
+ // Can be useful for debugging
+ // switch cmd.Command.RequestType {
+ // case "InstallProfile":
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
+ // case "InstallEnterpriseApplication":
+ // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
+ // } else {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
+ // }
+ // default:
+ // fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
+ // }
+
+ cmds = append(cmds, &fullCmd)
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ }
+
+ // expected commands: install fleetd (install enterprise), install profiles
+ // (custom one, fleetd configuration, fleet CA root)
+ require.Len(t, cmds, 4)
+ var installProfileCount, installEnterpriseCount, otherCount int
+ var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
+ for _, cmd := range cmds {
+ switch cmd.Command.RequestType {
+ case "InstallProfile":
+ installProfileCount++
+ switch {
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), "I1 "):
+ profileCustomSeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s ", mobileconfig.FleetdConfigPayloadIdentifier)):
+ profileFleetdSeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s ", mobileconfig.FleetCARootConfigPayloadIdentifier)):
+ profileFleetCASeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s >>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
+ // case "InstallEnterpriseApplication":
+ // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
+ // } else {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
+ // }
+ // default:
+ // fmt.Println(">>>> device received command: ", cmd.Command.RequestType)
+ // }
+
+ cmds = append(cmds, &fullCmd)
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ }
+
+ // expected commands: install fleetd (install enterprise), install profiles
+ // (custom one, fleetd configuration, fleet CA root)
+ require.Len(t, cmds, 4)
+ var installProfileCount, installEnterpriseCount, otherCount int
+ var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool
+ for _, cmd := range cmds {
+ switch cmd.Command.RequestType {
+ case "InstallProfile":
+ installProfileCount++
+ switch {
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), "I1 "):
+ profileCustomSeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s ", mobileconfig.FleetdConfigPayloadIdentifier)):
+ profileFleetdSeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s ", mobileconfig.FleetCARootConfigPayloadIdentifier)):
+ profileFleetCASeen = true
+ case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s 0 {
+ teamID, err := strconv.ParseUint(val[0], 10, 64)
+ if err != nil {
+ return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())}
+ }
+ // // TODO: do we want to allow end users to specify team_id=0? if so, we'll need to convert it to nil here so that we can
+ // // use it in the auth layer where team_id=0 is not allowed?
+ decoded.TeamID = ptr.Uint(uint(teamID))
+ }
+
+ fhs, ok := r.MultipartForm.File["script"]
+ if !ok || len(fhs) < 1 {
+ return nil, &fleet.BadRequestError{Message: "no file headers for script"}
+ }
+ decoded.Script = fhs[0]
+
+ return &decoded, nil
+}
+
+type setSetupExperienceScriptResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r setSetupExperienceScriptResponse) error() error { return r.Err }
+
+func setSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*setSetupExperienceScriptRequest)
+
+ scriptFile, err := req.Script.Open()
+ if err != nil {
+ return setSetupExperienceScriptResponse{Err: err}, nil
+ }
+ defer scriptFile.Close()
+
+ if err := svc.SetSetupExperienceScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile); err != nil {
+ return setSetupExperienceScriptResponse{Err: err}, nil
+ }
+
+ return setSetupExperienceScriptResponse{}, nil
+}
+
+func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return fleet.ErrMissingLicense
+}
+
+type deleteSetupExperienceScriptRequest struct {
+ TeamID *uint `query:"team_id,optional"`
+}
+
+type deleteSetupExperienceScriptResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r deleteSetupExperienceScriptResponse) error() error { return r.Err }
+
+// func (r deleteSetupExperienceScriptResponse) Status() int { return http.StatusNoContent }
+
+func deleteSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*deleteSetupExperienceScriptRequest)
+ // // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can
+ // // use it in the auth layer where team_id=0 is not allowed?
+ if err := svc.DeleteSetupExperienceScript(ctx, req.TeamID); err != nil {
+ return deleteSetupExperienceScriptResponse{Err: err}, nil
+ }
+
+ return deleteSetupExperienceScriptResponse{}, nil
+}
+
+func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return fleet.ErrMissingLicense
+}
+
+func (svc *Service) SetupExperienceNextStep(ctx context.Context, hostUUID string) (bool, error) {
+ // skipauth: No authorization check needed due to implementation returning
+ // only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return false, fleet.ErrMissingLicense
+}
+
+// maybeUpdateSetupExperienceStatus attempts to update the status of a setup experience result in
+// the database. If the given result is of a supported type (namely SetupExperienceScriptResult,
+// SetupExperienceSoftwareInstallResult, and SetupExperienceVPPInstallResult), it returns a boolean
+// indicating whether the datastore was updated and an error if one occurred. If the result is not of a
+// supported type, it returns false and an error indicated that the type is not supported.
+// If the skipPending parameter is true, the datastore will only be updated if the given result
+// status is not pending.
+func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result interface{}, requireTerminalStatus bool) (bool, error) {
+ switch v := result.(type) {
+ case fleet.SetupExperienceScriptResult:
+ status := v.SetupExperienceStatus()
+ if !status.IsValid() {
+ return false, fmt.Errorf("invalid status: %s", status)
+ } else if requireTerminalStatus && !status.IsTerminalStatus() {
+ return false, nil
+ }
+ return ds.MaybeUpdateSetupExperienceScriptStatus(ctx, v.HostUUID, v.ExecutionID, status)
+
+ case fleet.SetupExperienceSoftwareInstallResult:
+ status := v.SetupExperienceStatus()
+ fmt.Println(status)
+ if !status.IsValid() {
+ return false, fmt.Errorf("invalid status: %s", status)
+ } else if requireTerminalStatus && !status.IsTerminalStatus() {
+ return false, nil
+ }
+ return ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, v.HostUUID, v.ExecutionID, status)
+
+ case fleet.SetupExperienceVPPInstallResult:
+ // NOTE: this case is also implemented in the CommandAndReportResults method of
+ // MDMAppleCheckinAndCommandService
+ status := v.SetupExperienceStatus()
+ if !status.IsValid() {
+ return false, fmt.Errorf("invalid status: %s", status)
+ } else if requireTerminalStatus && !status.IsTerminalStatus() {
+ return false, nil
+ }
+ return ds.MaybeUpdateSetupExperienceVPPStatus(ctx, v.HostUUID, v.CommandUUID, status)
+
+ default:
+ return false, fmt.Errorf("unsupported result type: %T", result)
+ }
+}
diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go
new file mode 100644
index 000000000000..af1ca985ae2a
--- /dev/null
+++ b/server/service/setup_experience_test.go
@@ -0,0 +1,440 @@
+package service
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSetupExperienceAuth(t *testing.T) {
+ 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, SkipCreateTestUsers: true})
+
+ teamID := uint(1)
+ teamScriptID := uint(1)
+ noTeamScriptID := uint(2)
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error {
+ return nil
+ }
+
+ ds.GetSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) (*fleet.Script, error) {
+ if teamID == nil {
+ return &fleet.Script{ID: noTeamScriptID}, nil
+ }
+ switch *teamID {
+ case uint(1):
+ return &fleet.Script{ID: teamScriptID, TeamID: teamID}, nil
+ default:
+ return nil, newNotFoundError()
+ }
+ }
+ ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
+ return []byte("echo"), nil
+ }
+ ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error {
+ if teamID == nil {
+ return nil
+ }
+ switch *teamID {
+ case uint(1):
+ return nil
+ default:
+ return newNotFoundError() // TODO: confirm if we want to return not found on deletes
+ }
+ }
+ ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
+ return &fleet.Team{ID: id}, nil
+ }
+
+ testCases := []struct {
+ name string
+ user *fleet.User
+ shouldFailTeamWrite bool
+ shouldFailGlobalWrite bool
+ shouldFailTeamRead bool
+ shouldFailGlobalRead bool
+ }{
+ {
+ name: "global admin",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: false,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: false,
+ },
+ {
+ name: "global maintainer",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: false,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: false,
+ },
+ {
+ name: "global observer",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: false,
+ },
+ {
+ name: "global observer+",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: false,
+ },
+ {
+ name: "global gitops",
+ user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: false,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team admin, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team maintainer, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team observer, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team observer+, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: false,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team gitops, belongs to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
+ shouldFailTeamWrite: false,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team admin, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team maintainer, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team observer, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team observer+, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ {
+ name: "team gitops, DOES NOT belong to team",
+ user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
+ shouldFailTeamWrite: true,
+ shouldFailGlobalWrite: true,
+ shouldFailTeamRead: true,
+ shouldFailGlobalRead: true,
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
+
+ t.Run("setup experience script", func(t *testing.T) {
+ err := svc.SetSetupExperienceScript(ctx, nil, "test.sh", strings.NewReader("echo"))
+ checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+ err = svc.DeleteSetupExperienceScript(ctx, nil)
+ checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+ _, _, err = svc.GetSetupExperienceScript(ctx, nil, false)
+ checkAuthErr(t, tt.shouldFailGlobalRead, err)
+ _, _, err = svc.GetSetupExperienceScript(ctx, nil, true)
+ checkAuthErr(t, tt.shouldFailGlobalRead, err)
+
+ err = svc.SetSetupExperienceScript(ctx, &teamID, "test.sh", strings.NewReader("echo"))
+ checkAuthErr(t, tt.shouldFailTeamWrite, err)
+ err = svc.DeleteSetupExperienceScript(ctx, &teamID)
+ checkAuthErr(t, tt.shouldFailTeamWrite, err)
+ _, _, err = svc.GetSetupExperienceScript(ctx, &teamID, false)
+ checkAuthErr(t, tt.shouldFailTeamRead, err)
+ _, _, err = svc.GetSetupExperienceScript(ctx, &teamID, true)
+ checkAuthErr(t, tt.shouldFailTeamRead, err)
+ })
+ })
+ }
+}
+
+func TestMaybeUpdateSetupExperience(t *testing.T) {
+ ds := new(mock.Store)
+ // _, ctx := newTestService(t, ds, nil, nil, nil)
+ ctx := context.Background()
+
+ hostUUID := "host-uuid"
+ scriptUUID := "script-uuid"
+ softwareUUID := "software-uuid"
+ vppUUID := "vpp-uuid"
+
+ t.Run("unsupported result type", func(t *testing.T) {
+ _, err := maybeUpdateSetupExperienceStatus(ctx, ds, map[string]interface{}{"key": "value"}, true)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "unsupported result type")
+ })
+
+ t.Run("script results", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ exitCode int
+ expected fleet.SetupExperienceStatusResultStatus
+ alwaysUpdated bool
+ }{
+ {
+ name: "success",
+ exitCode: 0,
+ expected: fleet.SetupExperienceStatusSuccess,
+ alwaysUpdated: true,
+ },
+ {
+ name: "failure",
+ exitCode: 1,
+ expected: fleet.SetupExperienceStatusFailure,
+ alwaysUpdated: true,
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ds.MaybeUpdateSetupExperienceScriptStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ require.Equal(t, hostUUID, hostUUID)
+ require.Equal(t, executionID, scriptUUID)
+ require.Equal(t, tt.expected, status)
+ require.True(t, status.IsValid())
+ return true, nil
+ }
+ ds.MaybeUpdateSetupExperienceScriptStatusFuncInvoked = false
+
+ result := fleet.SetupExperienceScriptResult{
+ HostUUID: hostUUID,
+ ExecutionID: scriptUUID,
+ ExitCode: tt.exitCode,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, true)
+ require.NoError(t, err)
+ require.Equal(t, tt.alwaysUpdated, updated)
+ require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceScriptStatusFuncInvoked)
+ })
+ }
+ })
+
+ t.Run("software install results", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ status fleet.SoftwareInstallerStatus
+ expectStatus fleet.SetupExperienceStatusResultStatus
+ alwaysUpdated bool
+ }{
+ {
+ name: "success",
+ status: fleet.SoftwareInstalled,
+ expectStatus: fleet.SetupExperienceStatusSuccess,
+ alwaysUpdated: true,
+ },
+ {
+ name: "failure",
+ status: fleet.SoftwareInstallFailed,
+ expectStatus: fleet.SetupExperienceStatusFailure,
+ alwaysUpdated: true,
+ },
+ {
+ name: "pending",
+ status: fleet.SoftwareInstallPending,
+ expectStatus: fleet.SetupExperienceStatusPending,
+ alwaysUpdated: false,
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ requireTerminalStatus := true // when this flag is true, we don't expect pending status to update
+
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ require.Equal(t, hostUUID, hostUUID)
+ require.Equal(t, executionID, softwareUUID)
+ require.Equal(t, tt.expectStatus, status)
+ require.True(t, status.IsValid())
+ require.True(t, status.IsTerminalStatus())
+ return true, nil
+ }
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false
+
+ result := fleet.SetupExperienceSoftwareInstallResult{
+ HostUUID: hostUUID,
+ ExecutionID: softwareUUID,
+ InstallerStatus: tt.status,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus)
+ require.NoError(t, err)
+ require.Equal(t, tt.alwaysUpdated, updated)
+ require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked)
+
+ requireTerminalStatus = false // when this flag is false, we do expect pending status to update
+
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ require.Equal(t, hostUUID, hostUUID)
+ require.Equal(t, executionID, softwareUUID)
+ require.Equal(t, tt.expectStatus, status)
+ require.True(t, status.IsValid())
+ if status.IsTerminalStatus() {
+ require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure)
+ } else {
+ require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning)
+ }
+ return true, nil
+ }
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false
+ updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus)
+ require.NoError(t, err)
+ shouldUpdate := tt.alwaysUpdated
+ if tt.expectStatus == fleet.SetupExperienceStatusPending || tt.expectStatus == fleet.SetupExperienceStatusRunning {
+ shouldUpdate = true
+ }
+ require.Equal(t, shouldUpdate, updated)
+ require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked)
+ })
+ }
+ })
+
+ t.Run("vpp install results", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ status string
+ expected fleet.SetupExperienceStatusResultStatus
+ alwaysUpdated bool
+ }{
+ {
+ name: "success",
+ status: fleet.MDMAppleStatusAcknowledged,
+ expected: fleet.SetupExperienceStatusSuccess,
+ alwaysUpdated: true,
+ },
+ {
+ name: "failure",
+ status: fleet.MDMAppleStatusError,
+ expected: fleet.SetupExperienceStatusFailure,
+ alwaysUpdated: true,
+ },
+ {
+ name: "format error",
+ status: fleet.MDMAppleStatusCommandFormatError,
+ expected: fleet.SetupExperienceStatusFailure,
+ alwaysUpdated: true,
+ },
+ {
+ name: "pending",
+ status: fleet.MDMAppleStatusNotNow,
+ expected: fleet.SetupExperienceStatusPending,
+ alwaysUpdated: false,
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ requireTerminalStatus := true // when this flag is true, we don't expect pending status to update
+
+ ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ require.Equal(t, hostUUID, hostUUID)
+ require.Equal(t, cmdUUID, vppUUID)
+ require.Equal(t, tt.expected, status)
+ require.True(t, status.IsValid())
+ return true, nil
+ }
+ ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false
+
+ result := fleet.SetupExperienceVPPInstallResult{
+ HostUUID: hostUUID,
+ CommandUUID: vppUUID,
+ CommandStatus: tt.status,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus)
+ require.NoError(t, err)
+ require.Equal(t, tt.alwaysUpdated, updated)
+ require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked)
+
+ requireTerminalStatus = false // when this flag is false, we do expect pending status to update
+
+ ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ require.Equal(t, hostUUID, hostUUID)
+ require.Equal(t, cmdUUID, vppUUID)
+ require.Equal(t, tt.expected, status)
+ require.True(t, status.IsValid())
+ if status.IsTerminalStatus() {
+ require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure)
+ } else {
+ require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning)
+ }
+ return true, nil
+ }
+ ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false
+
+ updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus)
+ require.NoError(t, err)
+ shouldUpdate := tt.alwaysUpdated
+ if tt.expected == fleet.SetupExperienceStatusPending || tt.expected == fleet.SetupExperienceStatusRunning {
+ shouldUpdate = true
+ }
+ require.Equal(t, shouldUpdate, updated)
+ require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked)
+ })
+ }
+ })
+}
diff --git a/server/service/software_titles.go b/server/service/software_titles.go
index 91c5ce98e038..5d78ae8960a7 100644
--- a/server/service/software_titles.go
+++ b/server/service/software_titles.go
@@ -42,6 +42,13 @@ func listSoftwareTitlesEndpoint(ctx context.Context, request interface{}, svc fl
if sw.CountsUpdatedAt != nil && !sw.CountsUpdatedAt.IsZero() && sw.CountsUpdatedAt.After(latest) {
latest = *sw.CountsUpdatedAt
}
+ // we dont want to include the InstallDuringSetup field in the response
+ // for software titles list.
+ if sw.SoftwarePackage != nil {
+ sw.SoftwarePackage.InstallDuringSetup = nil
+ } else if sw.AppStoreApp != nil {
+ sw.AppStoreApp.InstallDuringSetup = nil
+ }
}
if len(titles) == 0 {
titles = []fleet.SoftwareTitleListResult{}
diff --git a/server/service/testing_client.go b/server/service/testing_client.go
index 8450808a94e5..fa49385ba816 100644
--- a/server/service/testing_client.go
+++ b/server/service/testing_client.go
@@ -6,11 +6,13 @@ import (
"encoding/json"
"fmt"
"io"
+ "mime/multipart"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
+ "path/filepath"
"regexp"
"sync"
"testing"
@@ -527,3 +529,61 @@ func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id ui
}
}
}
+
+func (ts *withServer) uploadSoftwareInstaller(
+ t *testing.T,
+ payload *fleet.UploadSoftwareInstallerPayload,
+ expectedStatus int,
+ expectedError string,
+) {
+ t.Helper()
+ openFile := func(name string) *os.File {
+ f, err := os.Open(filepath.Join("testdata", "software-installers", name))
+ require.NoError(t, err)
+ return f
+ }
+
+ f := openFile(payload.Filename)
+ defer f.Close()
+
+ payload.InstallerFile = f
+
+ var b bytes.Buffer
+ w := multipart.NewWriter(&b)
+
+ // add the software field
+ fw, err := w.CreateFormFile("software", payload.Filename)
+ require.NoError(t, err)
+ n, err := io.Copy(fw, payload.InstallerFile)
+ require.NoError(t, err)
+ require.NotZero(t, n)
+
+ // add the team_id field
+ if payload.TeamID != nil {
+ require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID)))
+ }
+ // add the remaining fields
+ require.NoError(t, w.WriteField("install_script", payload.InstallScript))
+ require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery))
+ require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript))
+ require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript))
+ if payload.SelfService {
+ require.NoError(t, w.WriteField("self_service", "true"))
+ }
+
+ w.Close()
+
+ headers := map[string]string{
+ "Content-Type": w.FormDataContentType(),
+ "Accept": "application/json",
+ "Authorization": fmt.Sprintf("Bearer %s", ts.token),
+ }
+
+ r := ts.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)
+ }
+}
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index b9a1a7152abf..055bbe6dbba7 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -743,6 +743,7 @@ func mdmConfigurationRequiredEndpoints() []struct {
{"GET", "/api/latest/fleet/bootstrap/summary", false, true},
{"PATCH", "/api/latest/fleet/mdm/apple/setup", false, true},
{"PATCH", "/api/latest/fleet/setup_experience", false, true},
+ {"POST", "/api/fleet/orbit/setup_experience/status", false, true},
}
}
diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go
index 52bf530d89b8..50c166a024f3 100644
--- a/server/worker/apple_mdm.go
+++ b/server/worker/apple_mdm.go
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
- "time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@@ -29,7 +28,9 @@ type AppleMDMTask string
const (
AppleMDMPostDEPEnrollmentTask AppleMDMTask = "post_dep_enrollment"
AppleMDMPostManualEnrollmentTask AppleMDMTask = "post_manual_enrollment"
- AppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device"
+ // deprecated job, not enqueued anymore but remains for backward
+ // compatibility (processing existing jobs after a fleet upgrade)
+ DeprecatedAppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device"
)
// AppleMDM is the job processor for the apple_mdm job.
@@ -78,8 +79,8 @@ func (a *AppleMDM) Run(ctx context.Context, argsJSON json.RawMessage) error {
err := a.runPostManualEnrollment(ctx, args)
return ctxerr.Wrap(ctx, err, "running post Apple manual enrollment task")
- case AppleMDMPostDEPReleaseDeviceTask:
- err := a.runPostDEPReleaseDevice(ctx, args)
+ case DeprecatedAppleMDMPostDEPReleaseDeviceTask:
+ err := a.deprecatedRunPostDEPReleaseDevice(ctx, args)
return ctxerr.Wrap(ctx, err, "running post Apple DEP release device task")
default:
@@ -104,22 +105,16 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg
}
func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) error {
- var awaitCmdUUIDs []string
-
if isMacOS(args.Platform) {
- fleetdCmdUUID, err := a.installFleetd(ctx, args.HostUUID)
+ _, err := a.installFleetd(ctx, args.HostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
}
- awaitCmdUUIDs = append(awaitCmdUUIDs, fleetdCmdUUID)
- bootstrapCmdUUID, err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID)
+ _, err = a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
}
- if bootstrapCmdUUID != "" {
- awaitCmdUUIDs = append(awaitCmdUUIDs, bootstrapCmdUUID)
- }
}
if ref := args.EnrollReference; ref != "" {
@@ -155,40 +150,19 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs)
); err != nil {
return ctxerr.Wrap(ctx, err, "sending AccountConfiguration command")
}
- awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUID)
- }
- }
-
- var manualRelease bool
- if args.TeamID == nil {
- ac, err := a.Datastore.AppConfig(ctx)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "get AppConfig to read enable_release_device_manually")
- }
- manualRelease = ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
- } else {
- tm, err := a.Datastore.Team(ctx, *args.TeamID)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "get Team to read enable_release_device_manually")
- }
- manualRelease = tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
- }
-
- if !manualRelease {
- // send all command uuids for the commands sent here during post-DEP
- // enrollment and enqueue a job to look for the status of those commands to
- // be final and same for MDM profiles of that host; it means the DEP
- // enrollment process is done and the device can be released.
- if err := QueueAppleMDMJob(ctx, a.Datastore, a.Log, AppleMDMPostDEPReleaseDeviceTask,
- args.HostUUID, args.Platform, args.TeamID, args.EnrollReference, awaitCmdUUIDs...); err != nil {
- return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job")
}
}
return nil
}
-func (a *AppleMDM) runPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error {
+// This job is deprecated because releasing devices is now done via the orbit
+// endpoint /setup_experience/status that is polled by a swift dialog UI window
+// during the setup process, and automatically releases the device once all
+// pending setup tasks are done. However, it must remain implemented in case
+// there are such jobs to process after a Fleet migration to a new version; we
+// just don't enqueue that job anymore.
+func (a *AppleMDM) deprecatedRunPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error {
// Edge cases:
// - if the device goes offline for a long time, should we go ahead and
// release after a while?
@@ -367,12 +341,7 @@ func QueueAppleMDMJob(
Platform: platform,
}
- // the release device task is always added with a delay
- var delay time.Duration
- if task == AppleMDMPostDEPReleaseDeviceTask {
- delay = 30 * time.Second
- }
- job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, delay)
+ job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, 0)
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing job")
}
diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go
index fadd38dd2423..8b497379aba0 100644
--- a/server/worker/apple_mdm_test.go
+++ b/server/worker/apple_mdm_test.go
@@ -218,11 +218,8 @@ func TestAppleMDM(t *testing.T) {
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)
- // the post-DEP release device job is pending
- require.Len(t, jobs, 1)
- require.Equal(t, appleMDMJobName, jobs[0].Name)
- require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
- require.Equal(t, 0, jobs[0].Retries) // hasn't run yet
+ // there is no post-DEP release device job anymore
+ require.Len(t, jobs, 0)
require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
})
@@ -298,11 +295,8 @@ func TestAppleMDM(t *testing.T) {
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)
- // the post-DEP release device job is pending
- require.Len(t, jobs, 1)
- require.Equal(t, appleMDMJobName, jobs[0].Name)
- require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
- require.Equal(t, 0, jobs[0].Retries) // hasn't run yet
+ // the post-DEP release device job is not queued anymore
+ require.Len(t, jobs, 0)
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
@@ -350,11 +344,8 @@ func TestAppleMDM(t *testing.T) {
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)
- // the post-DEP release device job is pending
- require.Len(t, jobs, 1)
- require.Equal(t, appleMDMJobName, jobs[0].Name)
- require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
- require.Equal(t, 0, jobs[0].Retries) // hasn't run yet
+ // the post-DEP release device job is not queued anymore
+ require.Len(t, jobs, 0)
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
@@ -484,11 +475,8 @@ func TestAppleMDM(t *testing.T) {
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)
- // the post-DEP release device job is pending, having failed its first attempt
- require.Len(t, jobs, 1)
- require.Equal(t, appleMDMJobName, jobs[0].Name)
- require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
- require.Equal(t, 0, jobs[0].Retries) // hasn't run yet
+ // the post-DEP release device job is not queued anymore
+ require.Len(t, jobs, 0)
// confirm that AccountConfiguration command was not enqueued
require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
@@ -540,11 +528,8 @@ func TestAppleMDM(t *testing.T) {
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)
- // the post-DEP release device job is pending
- require.Len(t, jobs, 1)
- require.Equal(t, appleMDMJobName, jobs[0].Name)
- require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask)
- require.Equal(t, 0, jobs[0].Retries) // hasn't run yet
+ // the post-DEP release device job is not queued anymore
+ require.Len(t, jobs, 0)
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "AccountConfiguration"}, getEnqueuedCommandTypes(t))
})
diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt
index bc3e7c0a22d2..dee903783df5 100644
--- a/tools/cloner-check/generated_files/appconfig.txt
+++ b/tools/cloner-check/generated_files/appconfig.txt
@@ -146,6 +146,13 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Script optjson.String
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Software optjson.Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware]
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Set bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Valid bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Value []*fleet.MacOSSetupSoftware
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware AppStoreID string
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string
github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSMigration fleet.MacOSMigration
github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Enable bool
github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Mode fleet.MacOSMigrationMode string
diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt
index 9c92a3696ee0..f21250550da8 100644
--- a/tools/cloner-check/generated_files/teammdm.txt
+++ b/tools/cloner-check/generated_files/teammdm.txt
@@ -28,6 +28,13 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Script optjson.String
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Software optjson.Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware]
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Set bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Valid bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Value []*fleet.MacOSSetupSoftware
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware AppStoreID string
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings
github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool
diff --git a/website/assets/images/install-software-preview.png b/website/assets/images/install-software-preview.png
new file mode 100644
index 000000000000..ea0647dcdbd8
Binary files /dev/null and b/website/assets/images/install-software-preview.png differ