) => {
+ e.preventDefault();
+
+ try {
+ await mdmAPI.updateReleaseDeviceSetting(currentTeamId, releaseDevice);
+ renderFlash("success", "Successfully updated.");
+ } catch {
+ renderFlash("error", "Something went wrong. Please try again.");
+ }
+ };
+
+ const tooltip = (
+ <>
+ When enabled, you're responsible for sending the DeviceConfigured
+ command. (Default: Off)
+ >
+ );
+
+ return (
+
+ setShowAdvancedOptions(!showAdvancedOptions)}
+ />
+ {showAdvancedOptions && (
+
+ )}
+
+ );
+};
+
+export default AdvancedOptionsForm;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss
new file mode 100644
index 0000000000..b08bafb01e
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss
@@ -0,0 +1,10 @@
+.advanced-options-form {
+ display: flex;
+ flex-direction: column;
+ gap: $pad-large;
+
+ &__accordion-title {
+ // use this so we dont center the text.
+ justify-content: normal;
+ }
+}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts
new file mode 100644
index 0000000000..1b538503ff
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./AdvancedOptionsForm";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx
new file mode 100644
index 0000000000..0ab9ef7026
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx
@@ -0,0 +1,59 @@
+import React, { useContext } from "react";
+
+import mdmAPI from "services/entities/mdm";
+
+import Modal from "components/Modal";
+import Button from "components/buttons/Button";
+import { NotificationContext } from "context/notification";
+
+interface DeleteAutoEnrollProfileProps {
+ currentTeamId: number;
+ onCancel: () => void;
+ onDelete: () => void;
+}
+
+const baseClass = "delete-auto-enrollment-profile-modal";
+
+const DeleteAutoEnrollProfile = ({
+ currentTeamId,
+ onCancel,
+ onDelete,
+}: DeleteAutoEnrollProfileProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+
+ const handleDelete = async () => {
+ try {
+ await mdmAPI.deleteSetupEnrollmentProfile(currentTeamId);
+ renderFlash("success", "Successfully deleted!");
+ } catch {
+ renderFlash("error", "Couldn’t delete. Please try again.");
+ }
+ onDelete();
+ };
+
+ return (
+
+ <>
+ Delete the automatic enrollment profile to upload a new one.
+
+ Without an automatic enrollment profile, new macOS hosts will
+ automatically enroll with the default setup settings.
+
+
+
+
+
+ >
+
+ );
+};
+
+export default DeleteAutoEnrollProfile;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts
new file mode 100644
index 0000000000..dfd08b7064
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts
@@ -0,0 +1 @@
+export { default } from "./DeleteAutoEnrollmentProfile";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx
new file mode 100644
index 0000000000..9e080644d6
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx
@@ -0,0 +1,35 @@
+import React from "react";
+
+import Card from "components/Card";
+
+import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif";
+
+const baseClass = "setup-assistant-preview";
+
+const SetupAssistantPreview = () => {
+ return (
+
+ End user experience
+
+ After the end user continues past the Remote Management screen,
+ macOS Setup Assistant displays several screens by default.
+
+
+ By adding an automatic enrollment profile you can customize which
+ screens are displayed and more.
+
+
+
+ );
+};
+
+export default SetupAssistantPreview;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss
new file mode 100644
index 0000000000..07f7e9b797
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss
@@ -0,0 +1,16 @@
+.setup-assistant-preview {
+ font-size: $x-small;
+
+ h2 {
+ margin: 0;
+ font-size: $small;
+ font-weight: normal;
+ }
+
+ &__preview-img {
+ margin-top: $pad-xxlarge;
+ width: 100%;
+ display: block;
+ margin: 40px auto 0;
+ }
+}
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts
new file mode 100644
index 0000000000..ecdc67cdf4
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupAssistantPreview";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx
new file mode 100644
index 0000000000..8d6d64dd41
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import FileSaver from "file-saver";
+
+import { uploadedFromNow } from "utilities/date_format";
+
+import Icon from "components/Icon";
+import Card from "components/Card";
+import Graphic from "components/Graphic";
+import Button from "components/buttons/Button";
+import { IAppleSetupEnrollmentProfileResponse } from "services/entities/mdm";
+
+const baseClass = "setup-assistant-profile-card";
+interface ISetupAssistantProfileCardProps {
+ profile: IAppleSetupEnrollmentProfileResponse;
+ onDelete: () => void;
+}
+
+const SetupAssistantProfileCard = ({
+ profile,
+ onDelete,
+}: ISetupAssistantProfileCardProps) => {
+ const onDownload = () => {
+ const date = new Date();
+ const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${
+ profile.name
+ }`;
+ const file = new global.window.File(
+ [JSON.stringify(profile.enrollment_profile)],
+ filename
+ );
+
+ FileSaver.saveAs(file);
+ };
+
+ return (
+
+
+
+ {profile.name}
+
+ {uploadedFromNow(profile.uploaded_at)}
+
+
+
+
+
+
+
+ );
+};
+
+export default SetupAssistantProfileCard;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss
new file mode 100644
index 0000000000..7d8d4bbc63
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss
@@ -0,0 +1,30 @@
+.setup-assistant-profile-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/SetupAssistant/components/SetupAssistantProfileCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts
new file mode 100644
index 0000000000..00e2af3c93
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupAssistantProfileCard";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx
new file mode 100644
index 0000000000..936e9f58fa
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx
@@ -0,0 +1,62 @@
+import React, { useContext, useState } from "react";
+import { AxiosResponse } from "axios";
+
+import { IApiError } from "interfaces/errors";
+import { NotificationContext } from "context/notification";
+import mdmAPI from "services/entities/mdm";
+
+import FileUploader from "components/FileUploader";
+
+import { getErrorMessage } from "./helpers";
+
+const baseClass = "setup-assistant-profile-uploader";
+
+interface ISetupAssistantProfileUploaderProps {
+ currentTeamId: number;
+ onUpload: () => void;
+}
+
+const SetupAssistantProfileUploader = ({
+ currentTeamId,
+ onUpload,
+}: ISetupAssistantProfileUploaderProps) => {
+ const { renderFlash } = useContext(NotificationContext);
+ const [showLoading, setShowLoading] = useState(false);
+
+ const onUploadFile = async (files: FileList | null) => {
+ setShowLoading(true);
+
+ if (!files || files.length === 0) {
+ setShowLoading(false);
+ return;
+ }
+
+ const file = files[0];
+
+ try {
+ await mdmAPI.uploadSetupEnrollmentProfile(file, currentTeamId);
+ renderFlash("success", "Successfully uploaded!");
+ onUpload();
+ } catch (e) {
+ const error = e as AxiosResponse;
+ const errMessage = getErrorMessage(error);
+ renderFlash("error", errMessage);
+ } finally {
+ setShowLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default SetupAssistantProfileUploader;
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts
new file mode 100644
index 0000000000..bd7538a73a
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts
@@ -0,0 +1,13 @@
+import { getErrorReason } from "interfaces/errors";
+
+const UPLOAD_ERROR_MESSAGES = {
+ default: {
+ message: "Couldn't upload. Please try again.",
+ },
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export const getErrorMessage = (err: unknown) => {
+ if (typeof err === "string") return err;
+ return getErrorReason(err) || UPLOAD_ERROR_MESSAGES.default.message;
+};
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts
new file mode 100644
index 0000000000..f27221879b
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupAssistantProfileUploader";
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts
new file mode 100644
index 0000000000..473124c799
--- /dev/null
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts
@@ -0,0 +1 @@
+export { default } from "./SetupAssistant";
diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts
index 4a1752c652..44add82148 100644
--- a/frontend/router/paths.ts
+++ b/frontend/router/paths.ts
@@ -14,6 +14,7 @@ export default {
CONTROLS_SETUP_EXPERIENCE: `${URL_PREFIX}/controls/setup-experience`,
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_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
DASHBOARD: `${URL_PREFIX}/dashboard`,
diff --git a/frontend/services/entities/config.ts b/frontend/services/entities/config.ts
index 78af25563d..685826a7dc 100644
--- a/frontend/services/entities/config.ts
+++ b/frontend/services/entities/config.ts
@@ -57,8 +57,8 @@ export default {
},
/**
- * updateMDMConfig is a special case of update that is used to update the MDM
- * config.
+ * updateMDMConfig is a special case of update that is used to update the app
+ * MDM config.
*
* If the request fails and `skipParseError` is `true`, the caller is
* responsible for verifying that the value of the rejected promise is an AxiosError
diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts
index 29a8e5fab9..90101a1135 100644
--- a/frontend/services/entities/mdm.ts
+++ b/frontend/services/entities/mdm.ts
@@ -4,6 +4,7 @@ import {
IMdmProfile,
MdmProfileStatus,
} from "interfaces/mdm";
+import { API_NO_TEAM_ID } from "interfaces/team";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
@@ -46,6 +47,19 @@ export interface IUploadProfileApiParams {
labels?: string[];
}
+interface IUpdateSetupExperienceBody {
+ team_id?: number;
+ enable_release_device_manually: boolean;
+}
+
+export interface IAppleSetupEnrollmentProfileResponse {
+ team_id: number | null;
+ name: string;
+ uploaded_at: string;
+ // enrollment profile is an object with keys found here https://developer.apple.com/documentation/devicemanagement/profile.
+ enrollment_profile: Record;
+}
+
const mdmService = {
downloadDeviceUserEnrollmentProfile: (token: string) => {
const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints;
@@ -226,6 +240,68 @@ const mdmService = {
enable_end_user_authentication: isEnabled,
});
},
+
+ updateReleaseDeviceSetting: (teamId: number, isEnabled: boolean) => {
+ const { MDM_SETUP_EXPERIENCE } = endpoints;
+
+ const body: IUpdateSetupExperienceBody = {
+ enable_release_device_manually: isEnabled,
+ };
+
+ if (teamId !== API_NO_TEAM_ID) {
+ body.team_id = teamId;
+ }
+
+ return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body);
+ },
+ getSetupEnrollmentProfile: (teamId?: number) => {
+ const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
+ if (!teamId || teamId === API_NO_TEAM_ID) {
+ return sendRequest("GET", MDM_APPLE_SETUP_ENROLLMENT_PROFILE);
+ }
+
+ const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams(
+ { team_id: teamId }
+ )}`;
+ return sendRequest("GET", path);
+ },
+ uploadSetupEnrollmentProfile: (file: File, teamId: number) => {
+ const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
+
+ const reader = new FileReader();
+ reader.readAsText(file);
+
+ return new Promise((resolve, reject) => {
+ reader.addEventListener("load", () => {
+ try {
+ const body: Record = {
+ name: file.name,
+ enrollment_profile: JSON.parse(reader.result as string),
+ };
+ if (teamId !== API_NO_TEAM_ID) {
+ body.team_id = teamId;
+ }
+ resolve(
+ sendRequest("POST", MDM_APPLE_SETUP_ENROLLMENT_PROFILE, body)
+ );
+ } catch {
+ // catches invalid JSON
+ reject("Couldn’t upload. The file should include valid JSON.");
+ }
+ });
+ });
+ },
+ deleteSetupEnrollmentProfile: (teamId: number) => {
+ const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
+ if (teamId === API_NO_TEAM_ID) {
+ return sendRequest("DELETE", MDM_APPLE_SETUP_ENROLLMENT_PROFILE);
+ }
+
+ const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams(
+ { team_id: teamId }
+ )}`;
+ return sendRequest("DELETE", path);
+ },
};
export default mdmService;
diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts
index 1495445827..8bea47a64d 100644
--- a/frontend/services/entities/teams.ts
+++ b/frontend/services/entities/teams.ts
@@ -140,6 +140,16 @@ export default {
return sendRequest("PATCH", path, requestBody);
},
+
+ /**
+ * updates the team config. This can take any partial data that is in the team config.
+ */
+ updateConfig: (data: any, teamId: number): Promise => {
+ const { TEAMS } = endpoints;
+ const path = `${TEAMS}/${teamId}`;
+ return sendRequest("PATCH", path, data);
+ },
+
addUsers: (teamId: number | undefined, newUsers: INewTeamUsersBody) => {
if (!teamId || teamId <= API_NO_TEAM_ID) {
return Promise.reject(
diff --git a/frontend/utilities/date_format/date_format.tests.ts b/frontend/utilities/date_format/date_format.tests.ts
new file mode 100644
index 0000000000..102f34d048
--- /dev/null
+++ b/frontend/utilities/date_format/date_format.tests.ts
@@ -0,0 +1,13 @@
+import { uploadedFromNow } from ".";
+
+describe("date_format", () => {
+ describe("uploadedFromNow util", () => {
+ it("returns an user friendly uploaded at message", () => {
+ const currentDate = new Date();
+ currentDate.setDate(currentDate.getDate() - 2);
+ const twoDaysAgo = currentDate.toISOString();
+
+ expect(uploadedFromNow(twoDaysAgo)).toEqual("Uploaded 2 days ago");
+ });
+ });
+});
diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts
new file mode 100644
index 0000000000..356eaef6f2
--- /dev/null
+++ b/frontend/utilities/date_format/index.ts
@@ -0,0 +1,6 @@
+import { formatDistanceToNow } from "date-fns";
+
+// eslint-disable-next-line import/prefer-default-export
+export const uploadedFromNow = (date: string) => {
+ return `Uploaded ${formatDistanceToNow(new Date(date))} ago`;
+};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index b54ec4fd97..58c2b4c5db 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -79,11 +79,13 @@ export default {
}
return `/api/mdm/apple/enroll?${query}`;
},
+ MDM_APPLE_SETUP_ENROLLMENT_PROFILE: `/${API_VERSION}/fleet/mdm/apple/enrollment_profile`,
MDM_BOOTSTRAP_PACKAGE_METADATA: (teamId: number) =>
`/${API_VERSION}/fleet/mdm/bootstrap/${teamId}/metadata`,
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`,
diff --git a/server/datastore/mysql/jobs.go b/server/datastore/mysql/jobs.go
index 3c68ec45b9..d674ad26c2 100644
--- a/server/datastore/mysql/jobs.go
+++ b/server/datastore/mysql/jobs.go
@@ -35,7 +35,7 @@ VALUES (?, ?, ?, ?, ?, COALESCE(?, NOW()))
return job, nil
}
-func (ds *Datastore) GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) {
+func (ds *Datastore) GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) {
query := `
SELECT
id, created_at, updated_at, name, args, state, retries, error, not_before
@@ -43,14 +43,18 @@ FROM
jobs
WHERE
state = ? AND
- not_before <= NOW()
+ not_before <= ?
ORDER BY
updated_at ASC
LIMIT ?
`
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+
var jobs []*fleet.Job
- err := sqlx.SelectContext(ctx, ds.reader(ctx), &jobs, query, fleet.JobStateQueued, maxNumJobs)
+ err := sqlx.SelectContext(ctx, ds.reader(ctx), &jobs, query, fleet.JobStateQueued, now, maxNumJobs)
if err != nil {
return nil, err
}
diff --git a/server/datastore/mysql/jobs_test.go b/server/datastore/mysql/jobs_test.go
index 177d54f22e..400003f57c 100644
--- a/server/datastore/mysql/jobs_test.go
+++ b/server/datastore/mysql/jobs_test.go
@@ -11,6 +11,9 @@ import (
func TestJobs(t *testing.T) {
ds := CreateMySQLDS(t)
+ // call TruncateTables before the first test, because a DB migation may have
+ // created job entries.
+ TruncateTables(t, ds)
cases := []struct {
name string
@@ -30,7 +33,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) {
ctx := context.Background()
// no jobs yet
- jobs, err := ds.GetQueuedJobs(ctx, 10)
+ jobs, err := ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Empty(t, jobs)
@@ -45,7 +48,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) {
require.NotZero(t, j2.ID)
// only j1 is returned
- jobs, err = ds.GetQueuedJobs(ctx, 10)
+ jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, j1.ID, jobs[0].ID)
@@ -58,7 +61,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// no jobs queued for now
- jobs, err = ds.GetQueuedJobs(ctx, 10)
+ jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Empty(t, jobs)
@@ -68,7 +71,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// j2 is returned
- jobs, err = ds.GetQueuedJobs(ctx, 10)
+ jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, j2.ID, jobs[0].ID)
diff --git a/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go
new file mode 100644
index 0000000000..3d7db95b63
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go
@@ -0,0 +1,64 @@
+package tables
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20240320145650, Down_20240320145650)
+}
+
+func Up_20240320145650(tx *sql.Tx) error {
+ // This migration is to re-generate and re-register with Apple the DEP
+ // enrollment profile(s) so that await_device_configured is set to true.
+ // We do this by doing the equivalent of:
+ //
+ // worker.QueueMacosSetupAssistantJob(ctx, ds, logger,
+ // worker.MacosSetupAssistantUpdateAllProfiles, nil)
+ //
+ // but without calling that function, in case the code changes in the future,
+ // breaking this migration. Instead we insert directly the job in the
+ // database, and the worker will process it shortly after Fleet restarts.
+
+ const (
+ jobName = "macos_setup_assistant"
+ taskName = "update_all_profiles"
+ jobStateQueued = "queued"
+ )
+
+ type macosSetupAssistantArgs struct {
+ Task string `json:"task"`
+ TeamID *uint `json:"team_id,omitempty"`
+ HostSerialNumbers []string `json:"host_serial_numbers,omitempty"`
+ }
+ argsJSON, err := json.Marshal(macosSetupAssistantArgs{Task: taskName})
+ if err != nil {
+ return fmt.Errorf("failed to JSON marshal the job arguments: %w", err)
+ }
+
+ // hard-coded timestamps are used so that schema.sql is stable
+ const query = `
+INSERT INTO jobs (
+ name,
+ args,
+ state,
+ error,
+ not_before,
+ created_at,
+ updated_at
+)
+VALUES (?, ?, ?, '', ?, ?, ?)
+`
+ ts := time.Date(2024, 3, 20, 0, 0, 0, 0, time.UTC)
+ if _, err := tx.Exec(query, jobName, argsJSON, jobStateQueued, ts, ts, ts); err != nil {
+ return fmt.Errorf("failed to insert worker job: %w", err)
+ }
+ return nil
+}
+
+func Down_20240320145650(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go
new file mode 100644
index 0000000000..5ec92d352a
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go
@@ -0,0 +1,52 @@
+package tables
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20240320145650(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ type macosSetupAssistantArgs struct {
+ Task string `json:"task"`
+ TeamID *uint `json:"team_id,omitempty"`
+ HostSerialNumbers []string `json:"host_serial_numbers,omitempty"`
+ }
+
+ type job struct {
+ ID uint `json:"id" db:"id"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+ UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
+ Name string `json:"name" db:"name"`
+ Args *json.RawMessage `json:"args" db:"args"`
+ State string `json:"state" db:"state"`
+ Retries int `json:"retries" db:"retries"`
+ Error string `json:"error" db:"error"`
+ NotBefore time.Time `json:"not_before" db:"not_before"`
+ }
+
+ var jobs []*job
+ err := db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs`)
+ require.NoError(t, err)
+ require.Empty(t, jobs)
+
+ applyNext(t, db)
+
+ err = db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs`)
+ require.NoError(t, err)
+ require.Len(t, jobs, 1)
+
+ require.Equal(t, "macos_setup_assistant", jobs[0].Name)
+ require.Equal(t, 0, jobs[0].Retries)
+ require.LessOrEqual(t, jobs[0].NotBefore, time.Now().UTC())
+ require.NotNil(t, jobs[0].Args)
+
+ var args macosSetupAssistantArgs
+ err = json.Unmarshal(*jobs[0].Args, &args)
+ require.NoError(t, err)
+ require.Equal(t, "update_all_profiles", args.Task)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index cc3c88c7ed..1b60c5b32e 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` (
UNIQUE KEY `id` (`id`)
) 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\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_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}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"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}, \"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, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"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\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
+INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"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}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"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}, \"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, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"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\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `calendar_events` (
@@ -585,8 +585,9 @@ CREATE TABLE `jobs` (
`error` text COLLATE utf8mb4_unicode_ci,
`not_before` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
+INSERT INTO `jobs` VALUES (1,'2024-03-20 00:00:00','2024-03-20 00:00:00','macos_setup_assistant','{\"task\": \"update_all_profiles\"}','queued',0,'','2024-03-20 00:00:00');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `label_membership` (
@@ -808,9 +809,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=259 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=260 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');
+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');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mobile_device_management_solutions` (
diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go
index 254f509095..bbaa8c7c5e 100644
--- a/server/datastore/mysql/teams_test.go
+++ b/server/datastore/mysql/teams_test.go
@@ -613,8 +613,9 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
GracePeriodDays: optjson.SetInt(3),
},
MacOSSetup: fleet.MacOSSetup{
- BootstrapPackage: optjson.SetString("bootstrap"),
- MacOSSetupAssistant: optjson.SetString("assistant"),
+ BootstrapPackage: optjson.SetString("bootstrap"),
+ MacOSSetupAssistant: optjson.SetString("assistant"),
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}),
diff --git a/server/fleet/app.go b/server/fleet/app.go
index e1720e5963..ac056347f0 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -381,6 +381,7 @@ 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"`
}
// MacOSMigration contains settings related to the MDM migration work flow.
@@ -819,6 +820,9 @@ func (c AppConfig) MarshalJSON() ([]byte, error) {
if !c.MDM.EnableDiskEncryption.Valid {
c.MDM.EnableDiskEncryption = optjson.SetBool(false)
}
+ if !c.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
+ c.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
+ }
type aliasConfig AppConfig
aa := aliasConfig(c)
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index 02a0b65f47..0b074b87dc 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -416,6 +416,7 @@ func (p MDMAppleSettingsPayload) AuthzType() string {
type MDMAppleSetupPayload struct {
TeamID *uint `json:"team_id"`
EnableEndUserAuthentication *bool `json:"enable_end_user_authentication"`
+ EnableReleaseDeviceManually *bool `json:"enable_release_device_manually"`
}
// AuthzType implements authz.AuthzTyper.
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index a2f8bf6cdd..827ba0be36 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -860,8 +860,9 @@ type Datastore interface {
// NewJob inserts a new job into the jobs table (queue).
NewJob(ctx context.Context, job *Job) (*Job, error)
- // GetQueuedJobs gets queued jobs from the jobs table (queue).
- GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*Job, error)
+ // GetQueuedJobs gets queued jobs from the jobs table (queue) ready to be
+ // processed. If now is the zero time, the current time will be used.
+ GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*Job, error)
// UpdateJobs updates an existing job. Call this after processing a job.
UpdateJob(ctx context.Context, id uint, job *Job) (*Job, error)
diff --git a/server/fleet/teams.go b/server/fleet/teams.go
index 9c9acbf4c0..55016fbb4b 100644
--- a/server/fleet/teams.go
+++ b/server/fleet/teams.go
@@ -120,6 +120,9 @@ func (t *Team) UnmarshalJSON(b []byte) error {
return err
}
+ if !x.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
+ x.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
+ }
*t = Team{
ID: x.ID,
CreatedAt: x.CreatedAt,
@@ -241,6 +244,10 @@ func (t *TeamConfig) Scan(val interface{}) error {
// Value implements the sql.Valuer interface
func (t TeamConfig) Value() (driver.Value, error) {
+ // force-save as the default `false` value if not set
+ if !t.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
+ t.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
+ }
return json.Marshal(t)
}
diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go
index b358238dce..0e9f79982d 100644
--- a/server/mdm/apple/apple_mdm.go
+++ b/server/mdm/apple/apple_mdm.go
@@ -91,17 +91,16 @@ type DEPService struct {
// getDefaultProfile returns a godep.Profile with default values set.
func (d *DEPService) getDefaultProfile() *godep.Profile {
return &godep.Profile{
- ProfileName: "FleetDM default enrollment profile",
- AllowPairing: true,
- AutoAdvanceSetup: false,
- AwaitDeviceConfigured: false,
- IsSupervised: false,
- IsMultiUser: false,
- IsMandatory: false,
- IsMDMRemovable: true,
- Language: "en",
- OrgMagic: "1",
- Region: "US",
+ ProfileName: "FleetDM default enrollment profile",
+ AllowPairing: true,
+ AutoAdvanceSetup: false,
+ IsSupervised: false,
+ IsMultiUser: false,
+ IsMandatory: false,
+ IsMDMRemovable: true,
+ Language: "en",
+ OrgMagic: "1",
+ Region: "US",
SkipSetupItems: []string{
"Accessibility",
"Appearance",
@@ -207,6 +206,10 @@ func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team
// ensure `url` is the same as `configuration_web_url`, to not leak the URL
// to get a token without SSO enabled
jsonProf.URL = jsonProf.ConfigurationWebURL
+ // always set await_device_configured to true - it will be released either
+ // automatically by Fleet or manually by the user if
+ // enable_release_device_manually is true.
+ jsonProf.AwaitDeviceConfigured = true
depClient := NewDEPClient(d.depStorage, d.ds, d.logger)
res, err := depClient.DefineProfile(ctx, DEPName, &jsonProf)
diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go
index 4485074a47..a03b5030d7 100644
--- a/server/mdm/apple/apple_mdm_test.go
+++ b/server/mdm/apple/apple_mdm_test.go
@@ -45,6 +45,7 @@ func TestDEPService(t *testing.T) {
require.Contains(t, got.ConfigurationWebURL, serverURL+"api/mdm/apple/enroll?token=")
got.URL = ""
got.ConfigurationWebURL = ""
+ defaultProfile.AwaitDeviceConfigured = true // this is now always set to true
require.Equal(t, defaultProfile, &got)
default:
require.Fail(t, "unexpected path: %s", r.URL.Path)
diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go
index 280596a0cb..24f8def567 100644
--- a/server/mdm/apple/commander.go
+++ b/server/mdm/apple/commander.go
@@ -226,6 +226,24 @@ func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUID
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
+func (svc *MDMAppleCommander) DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error {
+ raw := fmt.Sprintf(`
+
+
+
+ Command
+
+ RequestType
+ DeviceConfigured
+
+ CommandUUID
+ %s
+
+`, cmdUUID)
+
+ return svc.EnqueueCommand(ctx, []string{hostUUID}, raw)
+}
+
// EnqueueCommand takes care of enqueuing the commands and sending push
// notifications to the devices.
//
diff --git a/server/mdm/nanodep/godep/profile.go b/server/mdm/nanodep/godep/profile.go
index d8f7149ad9..267b1448c2 100644
--- a/server/mdm/nanodep/godep/profile.go
+++ b/server/mdm/nanodep/godep/profile.go
@@ -8,12 +8,14 @@ import (
// Profile corresponds to the Apple DEP API "Profile" structure.
// See https://developer.apple.com/documentation/devicemanagement/profile
type Profile struct {
- ProfileName string `json:"profile_name"`
- URL string `json:"url"`
- AllowPairing bool `json:"allow_pairing,omitempty"`
- IsSupervised bool `json:"is_supervised,omitempty"`
- IsMultiUser bool `json:"is_multi_user,omitempty"`
- IsMandatory bool `json:"is_mandatory,omitempty"`
+ ProfileName string `json:"profile_name"`
+ URL string `json:"url"`
+ AllowPairing bool `json:"allow_pairing,omitempty"`
+ IsSupervised bool `json:"is_supervised,omitempty"`
+ IsMultiUser bool `json:"is_multi_user,omitempty"`
+ IsMandatory bool `json:"is_mandatory,omitempty"`
+ // AwaitDeviceConfigured should never be set in the profiles we store in the
+ // database - it is now always forced to true when registering with Apple.
AwaitDeviceConfigured bool `json:"await_device_configured,omitempty"`
IsMDMRemovable bool `json:"is_mdm_removable"` // default true
SupportPhoneNumber string `json:"support_phone_number,omitempty"`
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 425e0945e7..f762e9b3aa 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -602,7 +602,7 @@ type SerialUpdateHostFunc func(ctx context.Context, host *fleet.Host) error
type NewJobFunc func(ctx context.Context, job *fleet.Job) (*fleet.Job, error)
-type GetQueuedJobsFunc func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error)
+type GetQueuedJobsFunc func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error)
type UpdateJobFunc func(ctx context.Context, id uint, job *fleet.Job) (*fleet.Job, error)
@@ -4221,11 +4221,11 @@ func (s *DataStore) NewJob(ctx context.Context, job *fleet.Job) (*fleet.Job, err
return s.NewJobFunc(ctx, job)
}
-func (s *DataStore) GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) {
+func (s *DataStore) GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) {
s.mu.Lock()
s.GetQueuedJobsFuncInvoked = true
s.mu.Unlock()
- return s.GetQueuedJobsFunc(ctx, maxNumJobs)
+ return s.GetQueuedJobsFunc(ctx, maxNumJobs, now)
}
func (s *DataStore) UpdateJob(ctx context.Context, id uint, job *fleet.Job) (*fleet.Job, error) {
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index 8a346183de..b2457d772d 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
+ "github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/rawjson"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
@@ -343,6 +344,19 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
} else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid {
appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption
}
+ // this is to handle the case where `enable_release_device_manually: null` is
+ // passed in the request payload, which should be treated as "not present/not
+ // changed" by the PATCH. We should really try to find a more general way to
+ // handle this.
+ if !oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
+ // this makes a DB migration unnecessary, will update the field to its default false value as necessary
+ oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
+ }
+ if newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid {
+ appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually
+ } else {
+ appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually
+ }
var legacyUsedWarning error
if legacyKeys := appConfig.DidUnmarshalLegacySettings(); len(legacyKeys) > 0 {
@@ -679,6 +693,9 @@ func (svc *Service) validateMDM(
if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !license.IsPremium() {
invalid.Append("macos_setup.macos_setup_assistant", ErrMissingLicense.Error())
}
+ if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value && !license.IsPremium() {
+ invalid.Append("macos_setup.enable_release_device_manually", ErrMissingLicense.Error())
+ }
if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value && !license.IsPremium() {
invalid.Append("macos_setup.bootstrap_package", ErrMissingLicense.Error())
}
@@ -699,6 +716,11 @@ func (svc *Service) validateMDM(
`Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
}
+ if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value {
+ invalid.Append("macos_setup.enable_release_device_manually",
+ `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
+ }
+
if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value {
invalid.Append("macos_setup.bootstrap_package",
`Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index 8881b19651..2e561eefca 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -815,7 +815,7 @@ func TestMDMAppleConfig(t *testing.T) {
name: "nochange",
licenseTier: "free",
expectedMDM: fleet.MDM{
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
@@ -845,7 +845,7 @@ func TestMDMAppleConfig(t *testing.T) {
newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"},
expectedMDM: fleet.MDM{
AppleBMDefaultTeam: "foobar",
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
@@ -860,7 +860,7 @@ func TestMDMAppleConfig(t *testing.T) {
newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"},
expectedMDM: fleet.MDM{
AppleBMDefaultTeam: "foobar",
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
@@ -881,7 +881,7 @@ func TestMDMAppleConfig(t *testing.T) {
oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}},
expectedMDM: fleet.MDM{
EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
@@ -905,7 +905,7 @@ 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}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
@@ -963,7 +963,7 @@ func TestMDMAppleConfig(t *testing.T) {
},
expectedMDM: fleet.MDM{
EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false},
- MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}},
+ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)},
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
WindowsSettings: fleet.WindowsSettings{
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index b531fd90bb..5e20e9e495 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -5601,6 +5601,19 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
s.Do("POST", "/api/v1/fleet/hosts/123/lock", nil, http.StatusPaymentRequired)
s.Do("POST", "/api/v1/fleet/hosts/123/unlock", nil, http.StatusPaymentRequired)
s.Do("POST", "/api/v1/fleet/hosts/123/wipe", nil, http.StatusPaymentRequired)
+
+ // try to update the enable_release_device_manually setting, requires premium
+ // (but /setup_experience catches the error of the MDM middleware check, so not
+ // StatusPaymentRequired)
+ res = s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusBadRequest)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error())
+
+ res = s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
+ "mdm": { "macos_setup": { "enable_release_device_manually": true } }
+ }`), http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "missing or invalid license")
}
func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() {
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 7466d78d14..70891e6a23 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/fleetdm/fleet/v4/server/pubsub"
"io"
"net/http"
"net/http/httptest"
@@ -20,6 +19,8 @@ import (
"testing"
"time"
+ "github.com/fleetdm/fleet/v4/server/pubsub"
+
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
@@ -168,8 +169,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
// because the MacOSSetup was marshalled to JSON to be saved in the DB,
// it did get marshalled, and then when unmarshalled it was set (but
// null).
- MacOSSetupAssistant: optjson.String{Set: true},
- BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ BootstrapPackage: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
// 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
@@ -261,8 +263,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
- MacOSSetupAssistant: optjson.String{Set: true},
- BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ BootstrapPackage: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -282,8 +285,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
- MacOSSetupAssistant: optjson.String{Set: true},
- BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ BootstrapPackage: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -305,8 +309,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{
- MacOSSetupAssistant: optjson.String{Set: true},
- BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ BootstrapPackage: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -395,6 +400,40 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't update macos_settings because MDM features aren't turned on in Fleet.")
+ // dry-run with macos enable release device set to false, no error
+ teamSpecs = map[string]any{
+ "specs": []any{
+ map[string]any{
+ "name": teamName,
+ "mdm": map[string]any{
+ "macos_setup": map[string]any{
+ "enable_release_device_manually": false,
+ },
+ },
+ },
+ },
+ }
+ applyResp = applyTeamSpecsResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
+ assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
+
+ // dry-run with macos enable release device manually set to true
+ teamSpecs = map[string]any{
+ "specs": []any{
+ map[string]any{
+ "name": teamName,
+ "mdm": map[string]any{
+ "macos_setup": map[string]any{
+ "enable_release_device_manually": true,
+ },
+ },
+ },
+ },
+ }
+ res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true")
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn't update macos_setup because MDM features aren't turned on in Fleet.")
+
// dry-run with invalid host_expiry_settings.host_expiry_window
teamSpecs = map[string]any{
"specs": []map[string]any{
@@ -1986,8 +2025,9 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() {
GracePeriodDays: optjson.SetInt(2),
},
MacOSSetup: fleet.MacOSSetup{
- MacOSSetupAssistant: optjson.String{Set: true},
- BootstrapPackage: optjson.String{Set: true},
+ MacOSSetupAssistant: optjson.String{Set: true},
+ BootstrapPackage: optjson.String{Set: true},
+ EnableReleaseDeviceManually: optjson.SetBool(false),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
@@ -3597,6 +3637,17 @@ func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() {
var reqCSRResp requestMDMAppleCSRResponse
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusOK, &reqCSRResp)
s.Do("POST", "/api/latest/fleet/mdm/apple/dep/key_pair", nil, http.StatusOK)
+
+ // setting enable release device manually requires MDM
+ res := s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusBadRequest)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error())
+
+ res = s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
+ "mdm": { "macos_setup": { "enable_release_device_manually": true } }
+ }`), http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, `Couldn't update macos_setup because MDM features aren't turned on in Fleet.`)
}
func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() {
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
new file mode 100644
index 0000000000..4e83e3c0fe
--- /dev/null
+++ b/server/service/integration_mdm_dep_test.go
@@ -0,0 +1,1083 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
+ "github.com/fleetdm/fleet/v4/server/datastore/mysql"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/worker"
+ kitlog "github.com/go-kit/log"
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+ micromdm "github.com/micromdm/micromdm/mdm/mdm"
+ "github.com/stretchr/testify/require"
+)
+
+type profileAssignmentReq struct {
+ ProfileUUID string `json:"profile_uuid"`
+ Devices []string `json:"devices"`
+}
+
+func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
+ t := s.T()
+ ctx := context.Background()
+
+ globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
+
+ // set an enroll secret, the Fleetd configuration profile will be installed
+ // on the host
+ enrollSecret := "test-release-dep-device"
+ err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}})
+ require.NoError(t, err)
+
+ // add a valid bootstrap package
+ b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
+ require.NoError(t, err)
+ signedPkg := b
+ s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: 0}, http.StatusOK, "")
+
+ // add a custom setup assistant and ensure enable_release_device_manually is
+ // false (the default)
+ noTeamProf := `{"x": 1}`
+ s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
+ TeamID: nil,
+ Name: "no-team",
+ EnrollmentProfile: json.RawMessage(noTeamProf),
+ }, http.StatusOK)
+ payload := map[string]any{
+ "enable_release_device_manually": false,
+ }
+ s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
+
+ // setup IdP so that AccountConfiguration profile is sent after DEP enrollment
+ var acResp appConfigResponse
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "end_user_authentication": {
+ "entity_id": "https://localhost:8080",
+ "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
+ "idp_name": "SimpleSAML",
+ "metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
+ },
+ "macos_setup": {
+ "enable_end_user_authentication": true
+ }
+ }
+ }`), http.StatusOK, &acResp)
+ require.NotEmpty(t, acResp.MDM.EndUserAuthentication)
+
+ // TODO(mna): how/where to pass an enroll_reference so that
+ // runPostDEPEnrollment sends an AccountConfiguration command?
+
+ // add a global profile
+ globalProfile := mobileconfigForTest("N1", "I1")
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent)
+
+ for _, enableReleaseManually := range []bool{false, true} {
+ t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
+ s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1")
+ })
+ }
+}
+
+func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
+ t := s.T()
+ ctx := context.Background()
+
+ teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}
+
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-team-device-release"})
+ require.NoError(t, err)
+
+ // set an enroll secret, the Fleetd configuration profile will be installed
+ // on the host
+ enrollSecret := "test-release-dep-device-team"
+ err = s.ds.ApplyEnrollSecrets(ctx, &tm.ID, []*fleet.EnrollSecret{{Secret: enrollSecret}})
+ require.NoError(t, err)
+
+ // add a valid bootstrap package
+ b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
+ require.NoError(t, err)
+ signedPkg := b
+ s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: tm.ID}, http.StatusOK, "")
+
+ // add a custom setup assistant and ensure enable_release_device_manually is
+ // false (the default)
+ teamProf := `{"y": 2}`
+ s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
+ TeamID: &tm.ID,
+ Name: "team",
+ EnrollmentProfile: json.RawMessage(teamProf),
+ }, http.StatusOK)
+ payload := map[string]any{
+ "enable_release_device_manually": false,
+ }
+ s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
+
+ // setup IdP so that AccountConfiguration profile is sent after DEP enrollment
+ var acResp appConfigResponse
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
+ "mdm": {
+ "apple_bm_default_team": %q,
+ "end_user_authentication": {
+ "entity_id": "https://localhost:8080",
+ "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
+ "idp_name": "SimpleSAML",
+ "metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
+ },
+ "macos_setup": {
+ "enable_end_user_authentication": true
+ }
+ }
+ }`, tm.Name)), http.StatusOK, &acResp)
+ require.NotEmpty(t, acResp.MDM.EndUserAuthentication)
+
+ // TODO(mna): how/where to pass an enroll_reference so that
+ // runPostDEPEnrollment sends an AccountConfiguration command?
+
+ // add a team profile
+ teamProfile := mobileconfigForTest("N2", "I2")
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
+
+ for _, enableReleaseManually := range []bool{false, true} {
+ t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
+ s.runDEPEnrollReleaseDeviceTest(t, teamDevice, enableReleaseManually, &tm.ID, "I2")
+ })
+ }
+}
+
+func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, device godep.Device, enableReleaseManually bool, teamID *uint, customProfileIdent string) {
+ ctx := context.Background()
+
+ // set the enable release device manually option
+ payload := map[string]any{
+ "enable_release_device_manually": enableReleaseManually,
+ }
+ if teamID != nil {
+ payload["team_id"] = *teamID
+ }
+ s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
+
+ // query all hosts - none yet
+ listHostsRes := listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ require.Empty(t, listHostsRes.Hosts)
+
+ s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ return map[string]*push.Response{}, nil
+ }
+
+ s.mockDEPResponse(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{device}})
+ require.NoError(t, err)
+ case "/devices/sync":
+ // This endpoint is polled over time to sync devices from
+ // ABM, send a repeated serial and a new one
+ err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}, 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()
+
+ 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, device.SerialNumber)
+
+ t.Cleanup(func() {
+ // delete the enrolled host
+ err := s.ds.DeleteHost(ctx, listHostsRes.Hosts[0].ID)
+ require.NoError(t, err)
+ })
+
+ // enroll the host
+ depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
+ mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
+ mdmDevice.SerialNumber = device.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 {
+ // Can be useful for debugging
+ //switch cmd.Command.RequestType {
+ //case "InstallProfile":
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(cmd.Command.InstallProfile.Payload))
+ //case "InstallEnterpriseApplication":
+ // if cmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *cmd.Command.InstallEnterpriseApplication.ManifestURL)
+ // } else {
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
+ // }
+ //default:
+ // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
+ //}
+ cmds = append(cmds, cmd)
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ }
+
+ // expected commands: install fleetd, install bootstrap, install profiles
+ // (custom one and fleetd configuration) (not expected: account
+ // configuration, since enrollment_reference not set)
+ require.Len(t, cmds, 4)
+ var installProfileCount, installEnterpriseCount, otherCount int
+ var profileCustomSeen, profileFleetdSeen bool
+ for _, cmd := range cmds {
+ switch cmd.Command.RequestType {
+ case "InstallProfile":
+ installProfileCount++
+ if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", customProfileIdent)) {
+ profileCustomSeen = true
+ } else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetdConfigPayloadIdentifier)) {
+ profileFleetdSeen = true
+ }
+
+ case "InstallEnterpriseApplication":
+ installEnterpriseCount++
+ default:
+ otherCount++
+ }
+ }
+ require.Equal(t, 2, installProfileCount)
+ require.Equal(t, 2, installEnterpriseCount)
+ require.Equal(t, 0, otherCount)
+ require.True(t, profileCustomSeen)
+ require.True(t, profileFleetdSeen)
+
+ if enableReleaseManually {
+ // get the worker's pending job from the future, there should not be any
+ // because it needs to be released manually
+ pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute))
+ require.NoError(t, err)
+ require.Empty(t, pending)
+ } else {
+ // 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)
+
+ // run the worker to process the DEP release
+ s.runWorker()
+
+ // make the device process the commands, it should receive the
+ // DeviceConfigured one.
+ cmds = cmds[:0]
+ cmd, err = mdmDevice.Idle()
+ require.NoError(t, err)
+ for cmd != nil {
+ cmds = append(cmds, cmd)
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ }
+
+ require.Len(t, cmds, 1)
+ var deviceConfiguredCount int
+ for _, cmd := range cmds {
+ switch cmd.Command.RequestType {
+ case "DeviceConfigured":
+ deviceConfiguredCount++
+ default:
+ otherCount++
+ }
+ }
+ require.Equal(t, 1, deviceConfiguredCount)
+ require.Equal(t, 0, otherCount)
+ }
+}
+
+func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
+ t := s.T()
+
+ ctx := context.Background()
+ devices := []godep.Device{
+ {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
+ {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
+ {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: ""},
+ {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "modified"},
+ }
+
+ profileAssignmentReqs := []profileAssignmentReq{}
+
+ // add global profiles
+ globalProfile := mobileconfigForTest("N1", "I1")
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent)
+
+ checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) {
+ // run the worker to process the DEP enroll request
+ s.runWorker()
+ // run the worker to assign configuration profiles
+ s.awaitTriggerProfileSchedule(t)
+
+ var fleetdCmd, installProfileCmd *micromdm.CommandPayload
+ cmd, err := mdmDevice.Idle()
+ require.NoError(t, err)
+ for cmd != nil {
+ if cmd.Command.RequestType == "InstallEnterpriseApplication" &&
+ cmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
+ strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
+ fleetdCmd = cmd
+ } else if cmd.Command.RequestType == "InstallProfile" {
+ installProfileCmd = cmd
+ }
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ }
+
+ if shouldReceive {
+ // received request to install fleetd
+ require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd")
+ require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd")
+
+ // received request to install the global configuration profile
+ require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles")
+ require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles")
+ } else {
+ require.Nil(t, fleetdCmd, "host got a command to install fleetd")
+ require.Nil(t, installProfileCmd, "host got a command to install profiles")
+ }
+ }
+
+ checkAssignProfileRequests := func(serial string, profUUID *string) {
+ require.NotEmpty(t, profileAssignmentReqs)
+ require.Len(t, profileAssignmentReqs, 1)
+ require.Len(t, profileAssignmentReqs[0].Devices, 1)
+ require.Equal(t, serial, profileAssignmentReqs[0].Devices[0])
+ if profUUID != nil {
+ require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID)
+ }
+ }
+
+ type hostDEPRow struct {
+ HostID uint `db:"host_id"`
+ ProfileUUID string `db:"profile_uuid"`
+ AssignProfileResponse string `db:"assign_profile_response"`
+ ResponseUpdatedAt time.Time `db:"response_updated_at"`
+ RetryJobID uint `db:"retry_job_id"`
+ }
+ checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow {
+ bySerial := make(map[string]hostDEPRow, len(deviceSerials))
+ for _, deviceSerial := range deviceSerials {
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ var dest hostDEPRow
+ err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial)
+ require.NoError(t, err)
+ require.Equal(t, string(expectedStatus), dest.AssignProfileResponse)
+ bySerial[deviceSerial] = dest
+ return nil
+ })
+ }
+ return bySerial
+ }
+
+ checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) {
+ pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{})
+ require.NoError(t, err)
+ require.Len(t, pending, 1)
+ require.Equal(t, "macos_setup_assistant", pending[0].Name)
+ require.NotNil(t, pending[0].Args)
+ var gotArgs struct {
+ Task string `json:"task"`
+ TeamID *uint `json:"team_id,omitempty"`
+ HostSerialNumbers []string `json:"host_serial_numbers,omitempty"`
+ }
+ require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs))
+ require.Equal(t, expectedTask, gotArgs.Task)
+ if expectedTeamID != nil {
+ require.NotNil(t, gotArgs.TeamID)
+ require.Equal(t, *expectedTeamID, *gotArgs.TeamID)
+ } else {
+ require.Nil(t, gotArgs.TeamID)
+ }
+ require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers)
+
+ if expectedJobID != 0 {
+ require.Equal(t, expectedJobID, pending[0].ID)
+ }
+ }
+
+ checkNoJobsPending := func() {
+ pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{})
+ require.NoError(t, err)
+ require.Empty(t, pending)
+ }
+
+ expectNoJobID := ptr.Uint(0) // used when expect no retry job
+ checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow {
+ bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status)
+ d, ok := bySerial[serial]
+ require.True(t, ok)
+ if expectUpdatedAt != nil {
+ require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt)
+ }
+ if expectRetryJobID != nil {
+ require.Equal(t, *expectRetryJobID, d.RetryJobID)
+ }
+ return d
+ }
+
+ checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse {
+ listHostsRes := listHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, 1)
+ require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial)
+ require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus)
+ require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError)
+
+ return &listHostsRes.Hosts[0]
+ }
+
+ setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) {
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial)
+ return err
+ })
+ }
+
+ expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow
+ expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow
+ s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ 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":
+ // This endpoint is used to get an initial list of
+ // devices, return a single device
+ err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
+ require.NoError(t, err)
+ case "/devices/sync":
+ // This endpoint is polled over time to sync devices from
+ // ABM, send a repeated serial and a new one
+ err := encoder.Encode(godep.DeviceResponse{Devices: devices, 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))
+ profileAssignmentReqs = append(profileAssignmentReqs, prof)
+ var resp godep.ProfileResponse
+ resp.ProfileUUID = prof.ProfileUUID
+ resp.Devices = make(map[string]string, len(prof.Devices))
+ for _, device := range prof.Devices {
+ switch device {
+ case expectAssignProfileResponseNotAccessible:
+ resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible)
+ case expectAssignProfileResponseFailed:
+ resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed)
+ default:
+ resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
+ }
+ }
+ err = encoder.Encode(resp)
+ require.NoError(t, err)
+ default:
+ _, _ = w.Write([]byte(`{}`))
+ }
+ }))
+
+ // query all hosts
+ listHostsRes := listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ require.Empty(t, listHostsRes.Hosts)
+
+ // trigger a profile sync
+ s.runDEPSchedule()
+
+ // all hosts should be returned from the hosts endpoint
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, len(devices))
+ var wantSerials []string
+ var gotSerials []string
+ for i, device := range devices {
+ wantSerials = append(wantSerials, device.SerialNumber)
+ gotSerials = append(gotSerials, listHostsRes.Hosts[i].HardwareSerial)
+ // entries for all hosts should be created in the host_dep_assignments table
+ _, err := s.ds.GetHostDEPAssignment(ctx, listHostsRes.Hosts[i].ID)
+ require.NoError(t, err)
+ }
+ require.ElementsMatch(t, wantSerials, gotSerials)
+ // called two times:
+ // - one when we get the initial list of devices (/server/devices)
+ // - one when we do the device sync (/device/sync)
+ require.Len(t, profileAssignmentReqs, 2)
+ require.Len(t, profileAssignmentReqs[0].Devices, 1)
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+ require.Len(t, profileAssignmentReqs[1].Devices, len(devices))
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+ // record the default profile to be used in other tests
+ defaultProfileUUID := profileAssignmentReqs[1].ProfileUUID
+
+ // create a new host
+ nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep")
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, len(devices)+1)
+
+ // filtering by MDM status works
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, len(devices))
+
+ // searching by display name works
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", url.QueryEscape("MacBook Mini")), nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, 3)
+ for _, host := range listHostsRes.Hosts {
+ require.Equal(t, "MacBook Mini", host.HardwareModel)
+ require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial))
+ }
+
+ s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ return map[string]*push.Response{}, nil
+ }
+
+ // Enroll one of the hosts
+ depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
+ mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
+ mdmDevice.SerialNumber = devices[0].SerialNumber
+ err := mdmDevice.Enroll()
+ require.NoError(t, err)
+
+ // make sure the host gets post enrollment requests
+ checkPostEnrollmentCommands(mdmDevice, true)
+
+ // only one shows up as pending
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, len(devices)-1)
+
+ activities := listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
+ found := false
+ for _, activity := range activities.Activities {
+ if activity.Type == "mdm_enrolled" &&
+ strings.Contains(string(*activity.Details), devices[0].SerialNumber) {
+ found = true
+ require.Nil(t, activity.ActorID)
+ require.Nil(t, activity.ActorFullName)
+ require.JSONEq(
+ t,
+ fmt.Sprintf(
+ `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
+ devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber,
+ ),
+ string(*activity.Details),
+ )
+ }
+ }
+ require.True(t, found)
+
+ // add devices[1].SerialNumber to a team
+ teamName := t.Name() + "team1"
+ team := &fleet.Team{
+ Name: teamName,
+ Description: "desc team1",
+ }
+ var createTeamResp teamResponse
+ s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
+ require.NotZero(t, createTeamResp.Team.ID)
+ team = createTeamResp.Team
+ for _, h := range listHostsRes.Hosts {
+ if h.HardwareSerial == devices[1].SerialNumber {
+ err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID})
+ require.NoError(t, err)
+ }
+ }
+
+ // modify the response and trigger another sync to include:
+ //
+ // 1. A repeated device with "added"
+ // 2. A repeated device with "modified"
+ // 3. A device with "deleted"
+ // 4. A new device
+ deletedSerial := devices[2].SerialNumber
+ addedSerial := uuid.New().String()
+ devices = []godep.Device{
+ {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added"},
+ {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini", OS: "osx", OpType: "modified"},
+ {SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted"},
+ {SerialNumber: addedSerial, Model: "MacBook Mini", OS: "osx", OpType: "added"},
+ }
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+
+ // all hosts should be returned from the hosts endpoint
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
+ // all previous devices + the manually added host + the new `addedSerial`
+ wantSerials = append(wantSerials, devices[3].SerialNumber, nonDEPHost.HardwareSerial)
+ require.Len(t, listHostsRes.Hosts, len(wantSerials))
+ gotSerials = []string{}
+ var deletedHostID uint
+ var addedHostID uint
+ var mdmDeviceID uint
+ for _, device := range listHostsRes.Hosts {
+ gotSerials = append(gotSerials, device.HardwareSerial)
+ switch device.HardwareSerial {
+ case deletedSerial:
+ deletedHostID = device.ID
+ case addedSerial:
+ addedHostID = device.ID
+ case mdmDevice.SerialNumber:
+ mdmDeviceID = device.ID
+ }
+ }
+ require.ElementsMatch(t, wantSerials, gotSerials)
+ require.Len(t, profileAssignmentReqs, 3)
+
+ // first request to get a list of profiles
+ // TODO: seems like we're doing this request on each loop?
+ require.Len(t, profileAssignmentReqs[0].Devices, 1)
+ require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // profileAssignmentReqs[1] and [2] can be in any order
+ ix2Devices, ix1Device := 1, 2
+ if len(profileAssignmentReqs[1].Devices) == 1 {
+ ix2Devices, ix1Device = ix1Device, ix2Devices
+ }
+
+ // - existing device with "added"
+ // - new device with "added"
+ require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs)
+ require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices)
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // - existing device with "modified" and a different team (thus different profile request)
+ require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1)
+ require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // entries for all hosts except for the one with OpType = "deleted"
+ assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID)
+ require.NoError(t, err)
+ require.NotZero(t, assignment.DeletedAt)
+
+ _, err = s.ds.GetHostDEPAssignment(ctx, addedHostID)
+ require.NoError(t, err)
+
+ // send a TokenUpdate command, it shouldn't re-send the post-enrollment commands
+ err = mdmDevice.TokenUpdate()
+ require.NoError(t, err)
+ checkPostEnrollmentCommands(mdmDevice, false)
+
+ // enroll the device again, it should get the post-enrollment commands
+ err = mdmDevice.Enroll()
+ require.NoError(t, err)
+ checkPostEnrollmentCommands(mdmDevice, true)
+
+ // delete the device from Fleet
+ var delResp deleteHostResponse
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmDeviceID), nil, http.StatusOK, &delResp)
+
+ // the device comes back as pending
+ listHostsRes = listHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", mdmDevice.UUID), nil, http.StatusOK, &listHostsRes)
+ require.Len(t, listHostsRes.Hosts, 1)
+ require.Equal(t, mdmDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial)
+
+ // we assign a DEP profile to the device
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runWorker()
+ require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // it should get the post-enrollment commands
+ require.NoError(t, mdmDevice.Enroll())
+ checkPostEnrollmentCommands(mdmDevice, true)
+
+ // delete all MDM info
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, listHostsRes.Hosts[0].ID)
+ return err
+ })
+
+ // it should still get the post-enrollment commands
+ require.NoError(t, mdmDevice.Enroll())
+ checkPostEnrollmentCommands(mdmDevice, true)
+
+ // The user unenrolls from Fleet (e.g. was DEP enrolled but with `is_mdm_removable: true`
+ // so the user removes the enrollment profile).
+ err = mdmDevice.Checkout()
+ require.NoError(t, err)
+
+ // Simulate a refetch where we clean up the MDM data since the host is not enrolled anymore
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, mdmDeviceID)
+ return err
+ })
+
+ // Simulate fleetd re-enrolling automatically.
+ err = mdmDevice.Enroll()
+ require.NoError(t, err)
+
+ // The last activity should have `installed_from_dep=true`.
+ s.lastActivityMatches(
+ "mdm_enrolled",
+ fmt.Sprintf(
+ `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
+ mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber,
+ ),
+ 0,
+ )
+
+ // enroll a host into Fleet
+ eHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
+ ID: 1,
+ OsqueryHostID: ptr.String("Desktop-ABCQWE"),
+ NodeKey: ptr.String("Desktop-ABCQWE"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local", s.T().Name()),
+ Platform: "darwin",
+ HardwareSerial: uuid.New().String(),
+ })
+ require.NoError(t, err)
+
+ // on team transfer, we don't assign a DEP profile to the device
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runWorker()
+ require.Empty(t, profileAssignmentReqs)
+
+ // assign the host in ABM
+ devices = []godep.Device{
+ {SerialNumber: eHost.HardwareSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified"},
+ }
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ require.NotEmpty(t, profileAssignmentReqs)
+ require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // report MDM info via osquery
+ require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, ""))
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
+
+ // transfer to "no team", we assign a DEP profile to the device
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ s.runWorker()
+ require.NotEmpty(t, profileAssignmentReqs)
+ require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
+
+ // transfer to the team back again, we assign a DEP profile to the device again
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runWorker()
+ require.NotEmpty(t, profileAssignmentReqs)
+ require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
+
+ // transfer to "no team", but simulate a failed profile assignment
+ expectAssignProfileResponseFailed = eHost.HardwareSerial
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
+
+ s.runIntegrationsSchedule()
+ checkAssignProfileRequests(eHost.HardwareSerial, nil)
+ profUUID := profileAssignmentReqs[0].ProfileUUID
+ d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
+ require.NotZero(t, d.ResponseUpdatedAt)
+ failedAt := d.ResponseUpdatedAt
+ checkNoJobsPending()
+ // list hosts shows dep profile error
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
+
+ // run the integrations schedule during the cooldown period
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // no new request during cooldown
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // create a new team
+ var tmResp teamResponse
+ s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
+ Name: t.Name() + "dummy",
+ Description: "desc dummy",
+ }, http.StatusOK, &tmResp)
+ require.NotZero(t, createTeamResp.Team.ID)
+ dummyTeam := tmResp.Team
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
+
+ // expect no assign profile request during cooldown
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // screened for cooldown
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // cooldown hosts are screened from update profile jobs that would assign profiles
+ _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial)
+ require.NoError(t, err)
+ checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // screened for cooldown
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // cooldown hosts are screened from delete profile jobs that would assign profiles
+ _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial)
+ require.NoError(t, err)
+ checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // screened for cooldown
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // // TODO: Restore this test when FIXME on DeleteTeam is addressed
+ // s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK)
+ // checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0)
+ // s.runIntegrationsSchedule()
+ // require.Empty(t, profileAssignmentReqs) // screened for cooldown
+ // bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed)
+ // d, ok = bySerial[eHost.HardwareSerial]
+ // require.True(t, ok)
+ // require.Equal(t, failedAt, d.ResponseUpdatedAt)
+ // require.Zero(t, d.RetryJobID) // cooling down so no retry job
+ // checkNoJobsPending()
+
+ // transfer back to no team, expect no assign profile request during cooldown
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
+ checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // screened for cooldown
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // simulate expired cooldown
+ failedAt = failedAt.Add(-2 * time.Hour)
+ setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
+ d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
+ require.NotZero(t, d.RetryJobID) // retry job created
+ jobID := d.RetryJobID
+ checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
+
+ // running the DEP schedule should not trigger a profile assignment request when the retry job is pending
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change
+ checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
+
+ // run the inregration schedule and expect success
+ expectAssignProfileResponseFailed = ""
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ checkAssignProfileRequests(eHost.HardwareSerial, &profUUID)
+ d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
+ require.True(t, d.ResponseUpdatedAt.After(failedAt))
+ succeededAt := d.ResponseUpdatedAt
+ checkNoJobsPending()
+ checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
+
+ // run the integrations schedule and expect no changes
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs)
+ checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // ingest new device via DEP but the profile assignment fails
+ serial := uuid.NewString()
+ devices = []godep.Device{
+ {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
+ }
+ expectAssignProfileResponseFailed = serial
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ checkAssignProfileRequests(serial, nil)
+ profUUID = profileAssignmentReqs[0].ProfileUUID
+ d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
+ require.NotZero(t, d.ResponseUpdatedAt)
+ failedAt = d.ResponseUpdatedAt
+ checkNoJobsPending()
+ h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error
+
+ // transfer to team, no profile assignment request is made during the cooldown period
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK)
+ checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0)
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // screened by cooldown
+ checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // run the integrations schedule and expect no changes
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs)
+ checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // simulate expired cooldown
+ failedAt = failedAt.Add(-2 * time.Hour)
+ setAssignProfileResponseUpdatedAt(serial, failedAt)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
+ d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
+ require.NotZero(t, d.RetryJobID) // retry job created
+ jobID = d.RetryJobID
+ checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID)
+
+ // run the inregration schedule and expect success
+ expectAssignProfileResponseFailed = ""
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ checkAssignProfileRequests(serial, nil)
+ require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead
+ profUUID = profileAssignmentReqs[0].ProfileUUID
+ d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
+ require.True(t, d.ResponseUpdatedAt.After(failedAt))
+ checkNoJobsPending()
+ // list hosts shows pending (because MDM detail query hasn't been reported) but dep profile
+ // error has been cleared
+ checkListHostDEPError(serial, "Pending", false)
+
+ // ingest another device via DEP but the profile assignment is not accessible
+ serial = uuid.NewString()
+ devices = []godep.Device{
+ {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
+ }
+ expectAssignProfileResponseNotAccessible = serial
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase
+ expectProfileUUID := ""
+ for _, req := range profileAssignmentReqs {
+ require.Len(t, req.Devices, 1)
+ require.Equal(t, serial, req.Devices[0])
+ if expectProfileUUID == "" {
+ expectProfileUUID = req.ProfileUUID
+ } else {
+ require.Equal(t, expectProfileUUID, req.ProfileUUID)
+ }
+ d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried
+ require.NotZero(t, d.ResponseUpdatedAt)
+ failedAt = d.ResponseUpdatedAt
+ }
+ // list hosts shows device pending and no dep profile error for not accessible responses
+ checkListHostDEPError(serial, "Pending", false)
+
+ // no retry job for not accessible responses even if cooldown expires
+ failedAt = failedAt.Add(-2 * time.Hour)
+ setAssignProfileResponseUpdatedAt(serial, failedAt)
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runIntegrationsSchedule()
+ require.Empty(t, profileAssignmentReqs)
+ checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change
+ checkNoJobsPending()
+
+ // run with devices that already have valid and invalid profiles
+ // assigned, we shouldn't re-assign the valid ones.
+ devices = []godep.Device{
+ {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
+ {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
+ {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: "bar"}, // doesn't match an existing profile
+ {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: "foo"}, // doesn't match an existing profile
+ {SerialNumber: addedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
+ {SerialNumber: serial, Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
+ }
+ expectAssignProfileResponseNotAccessible = ""
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ require.NotEmpty(t, profileAssignmentReqs)
+ require.Len(t, profileAssignmentReqs[0].Devices, 2)
+ require.ElementsMatch(t, []string{devices[2].SerialNumber, devices[3].SerialNumber}, profileAssignmentReqs[0].Devices)
+ checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
+
+ // run with only a device that already has the right profile, no errors and no assignments
+ devices = []godep.Device{
+ {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
+ }
+ profileAssignmentReqs = []profileAssignmentReq{}
+ s.runDEPSchedule()
+ require.Empty(t, profileAssignmentReqs)
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index aaa2b73dc0..39d322cba5 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -116,29 +116,37 @@ func (s *integrationMDMTestSuite) SetupSuite() {
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
require.NoError(s.T(), err)
+ pushLog := kitlog.NewJSONLogger(os.Stdout)
+ if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
+ pushLog = kitlog.NewNopLogger()
+ }
pushFactory, pushProvider := newMockAPNSPushProviderFactory()
mdmPushService := nanomdm_pushsvc.New(
mdmStorage,
mdmStorage,
pushFactory,
- NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
+ NewNanoMDMLogger(pushLog),
)
mdmCommander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
s.withServer.lq = live_query_mock.New(s.T())
+ wlog := kitlog.NewJSONLogger(os.Stdout)
+ if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
+ wlog = kitlog.NewNopLogger()
+ }
macosJob := &worker.MacosSetupAssistant{
Datastore: s.ds,
- Log: kitlog.NewJSONLogger(os.Stdout),
- DEPService: apple_mdm.NewDEPService(s.ds, depStorage, kitlog.NewJSONLogger(os.Stdout)),
- DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, kitlog.NewJSONLogger(os.Stdout)),
+ Log: wlog,
+ DEPService: apple_mdm.NewDEPService(s.ds, depStorage, wlog),
+ DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, wlog),
}
appleMDMJob := &worker.AppleMDM{
Datastore: s.ds,
- Log: kitlog.NewJSONLogger(os.Stdout),
+ Log: wlog,
Commander: mdmCommander,
}
- workr := worker.NewWorker(s.ds, kitlog.NewJSONLogger(os.Stdout))
+ workr := worker.NewWorker(s.ds, wlog)
workr.TestIgnoreUnknownJobs = true
workr.Register(macosJob, appleMDMJob)
s.worker = workr
@@ -146,6 +154,10 @@ func (s *integrationMDMTestSuite) SetupSuite() {
var depSchedule *schedule.Schedule
var integrationsSchedule *schedule.Schedule
var profileSchedule *schedule.Schedule
+ cronLog := kitlog.NewJSONLogger(os.Stdout)
+ if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
+ cronLog = kitlog.NewNopLogger()
+ }
config := TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
@@ -161,7 +173,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronAppleMDMDEPProfileAssigner)
- logger := kitlog.NewJSONLogger(os.Stdout)
+ logger := cronLog
fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger)
depSchedule = schedule.New(
ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
@@ -181,7 +193,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronMDMAppleProfileManager)
- logger := kitlog.NewJSONLogger(os.Stdout)
+ logger := cronLog
profileSchedule = schedule.New(
ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
schedule.WithLogger(logger),
@@ -208,7 +220,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronWorkerIntegrations)
- logger := kitlog.NewJSONLogger(os.Stdout)
+ logger := cronLog
integrationsSchedule = schedule.New(
ctx, name, s.T().Name(), 1*time.Minute, ds, ds,
schedule.WithLogger(logger),
@@ -288,6 +300,8 @@ func (s *integrationMDMTestSuite) TearDownTest() {
appCfg.MDM.WindowsEnabledAndConfigured = true
// ensure global disk encryption is disabled on exit
appCfg.MDM.EnableDiskEncryption = optjson.SetBool(false)
+ // ensure enable release manually is false
+ appCfg.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false)
// ensure global Windows OS updates are always disabled for the next test
appCfg.MDM.WindowsUpdates = fleet.WindowsUpdates{}
err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig)
@@ -305,6 +319,10 @@ func (s *integrationMDMTestSuite) TearDownTest() {
_, err := q.ExecContext(ctx, "DELETE FROM mdm_windows_configuration_profiles")
return err
})
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "DELETE FROM mdm_apple_bootstrap_packages")
+ return err
+ })
// clear any pending worker job
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
@@ -1981,745 +1999,6 @@ func createWindowsHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t
return host, mdmDevice
}
-func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
- t := s.T()
-
- ctx := context.Background()
- devices := []godep.Device{
- {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
- {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
- {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: ""},
- {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "modified"},
- }
-
- type profileAssignmentReq struct {
- ProfileUUID string `json:"profile_uuid"`
- Devices []string `json:"devices"`
- }
- profileAssignmentReqs := []profileAssignmentReq{}
-
- // add global profiles
- globalProfile := mobileconfigForTest("N1", "I1")
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent)
-
- checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) {
- // run the worker to process the DEP enroll request
- s.runWorker()
- // run the worker to assign configuration profiles
- s.awaitTriggerProfileSchedule(t)
-
- var fleetdCmd, installProfileCmd *micromdm.CommandPayload
- cmd, err := mdmDevice.Idle()
- require.NoError(t, err)
- for cmd != nil {
- if cmd.Command.RequestType == "InstallEnterpriseApplication" &&
- cmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
- strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
- fleetdCmd = cmd
- } else if cmd.Command.RequestType == "InstallProfile" {
- installProfileCmd = cmd
- }
- cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
- require.NoError(t, err)
- }
-
- if shouldReceive {
- // received request to install fleetd
- require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd")
- require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd")
-
- // received request to install the global configuration profile
- require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles")
- require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles")
- } else {
- require.Nil(t, fleetdCmd, "host got a command to install fleetd")
- require.Nil(t, installProfileCmd, "host got a command to install profiles")
- }
- }
-
- checkAssignProfileRequests := func(serial string, profUUID *string) {
- require.NotEmpty(t, profileAssignmentReqs)
- require.Len(t, profileAssignmentReqs, 1)
- require.Len(t, profileAssignmentReqs[0].Devices, 1)
- require.Equal(t, serial, profileAssignmentReqs[0].Devices[0])
- if profUUID != nil {
- require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID)
- }
- }
-
- type hostDEPRow struct {
- HostID uint `db:"host_id"`
- ProfileUUID string `db:"profile_uuid"`
- AssignProfileResponse string `db:"assign_profile_response"`
- ResponseUpdatedAt time.Time `db:"response_updated_at"`
- RetryJobID uint `db:"retry_job_id"`
- }
- checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow {
- bySerial := make(map[string]hostDEPRow, len(deviceSerials))
- for _, deviceSerial := range deviceSerials {
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- var dest hostDEPRow
- err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial)
- require.NoError(t, err)
- require.Equal(t, string(expectedStatus), dest.AssignProfileResponse)
- bySerial[deviceSerial] = dest
- return nil
- })
- }
- return bySerial
- }
-
- checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) {
- pending, err := s.ds.GetQueuedJobs(context.Background(), 1)
- require.NoError(t, err)
- require.Len(t, pending, 1)
- require.Equal(t, "macos_setup_assistant", pending[0].Name)
- require.NotNil(t, pending[0].Args)
- var gotArgs struct {
- Task string `json:"task"`
- TeamID *uint `json:"team_id,omitempty"`
- HostSerialNumbers []string `json:"host_serial_numbers,omitempty"`
- }
- require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs))
- require.Equal(t, expectedTask, gotArgs.Task)
- if expectedTeamID != nil {
- require.NotNil(t, gotArgs.TeamID)
- require.Equal(t, *expectedTeamID, *gotArgs.TeamID)
- } else {
- require.Nil(t, gotArgs.TeamID)
- }
- require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers)
-
- if expectedJobID != 0 {
- require.Equal(t, expectedJobID, pending[0].ID)
- }
- }
-
- checkNoJobsPending := func() {
- pending, err := s.ds.GetQueuedJobs(context.Background(), 1)
- require.NoError(t, err)
- require.Empty(t, pending)
- }
-
- expectNoJobID := ptr.Uint(0) // used when expect no retry job
- checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow {
- bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status)
- d, ok := bySerial[serial]
- require.True(t, ok)
- if expectUpdatedAt != nil {
- require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt)
- }
- if expectRetryJobID != nil {
- require.Equal(t, *expectRetryJobID, d.RetryJobID)
- }
- return d
- }
-
- checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse {
- listHostsRes := listHostsResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, 1)
- require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial)
- require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus)
- require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError)
-
- return &listHostsRes.Hosts[0]
- }
-
- setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) {
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial)
- return err
- })
- }
-
- expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow
- expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow
- s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- 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":
- // This endpoint is used to get an initial list of
- // devices, return a single device
- err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
- require.NoError(t, err)
- case "/devices/sync":
- // This endpoint is polled over time to sync devices from
- // ABM, send a repeated serial and a new one
- err := encoder.Encode(godep.DeviceResponse{Devices: devices, 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))
- profileAssignmentReqs = append(profileAssignmentReqs, prof)
- var resp godep.ProfileResponse
- resp.ProfileUUID = prof.ProfileUUID
- resp.Devices = make(map[string]string, len(prof.Devices))
- for _, device := range prof.Devices {
- switch device {
- case expectAssignProfileResponseNotAccessible:
- resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible)
- case expectAssignProfileResponseFailed:
- resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed)
- default:
- resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
- }
- }
- err = encoder.Encode(resp)
- require.NoError(t, err)
- default:
- _, _ = w.Write([]byte(`{}`))
- }
- }))
-
- // query all hosts
- listHostsRes := listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
- require.Empty(t, listHostsRes.Hosts)
-
- // trigger a profile sync
- s.runDEPSchedule()
-
- // all hosts should be returned from the hosts endpoint
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, len(devices))
- var wantSerials []string
- var gotSerials []string
- for i, device := range devices {
- wantSerials = append(wantSerials, device.SerialNumber)
- gotSerials = append(gotSerials, listHostsRes.Hosts[i].HardwareSerial)
- // entries for all hosts should be created in the host_dep_assignments table
- _, err := s.ds.GetHostDEPAssignment(ctx, listHostsRes.Hosts[i].ID)
- require.NoError(t, err)
- }
- require.ElementsMatch(t, wantSerials, gotSerials)
- // called two times:
- // - one when we get the initial list of devices (/server/devices)
- // - one when we do the device sync (/device/sync)
- require.Len(t, profileAssignmentReqs, 2)
- require.Len(t, profileAssignmentReqs[0].Devices, 1)
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
- require.Len(t, profileAssignmentReqs[1].Devices, len(devices))
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
- // record the default profile to be used in other tests
- defaultProfileUUID := profileAssignmentReqs[1].ProfileUUID
-
- // create a new host
- nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep")
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, len(devices)+1)
-
- // filtering by MDM status works
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, len(devices))
-
- // searching by display name works
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", url.QueryEscape("MacBook Mini")), nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, 3)
- for _, host := range listHostsRes.Hosts {
- require.Equal(t, "MacBook Mini", host.HardwareModel)
- require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial))
- }
-
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
- return map[string]*push.Response{}, nil
- }
-
- // Enroll one of the hosts
- depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
- mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
- mdmDevice.SerialNumber = devices[0].SerialNumber
- err := mdmDevice.Enroll()
- require.NoError(t, err)
-
- // make sure the host gets post enrollment requests
- checkPostEnrollmentCommands(mdmDevice, true)
-
- // only one shows up as pending
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, len(devices)-1)
-
- activities := listActivitiesResponse{}
- s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
- found := false
- for _, activity := range activities.Activities {
- if activity.Type == "mdm_enrolled" &&
- strings.Contains(string(*activity.Details), devices[0].SerialNumber) {
- found = true
- require.Nil(t, activity.ActorID)
- require.Nil(t, activity.ActorFullName)
- require.JSONEq(
- t,
- fmt.Sprintf(
- `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
- devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber,
- ),
- string(*activity.Details),
- )
- }
- }
- require.True(t, found)
-
- // add devices[1].SerialNumber to a team
- teamName := t.Name() + "team1"
- team := &fleet.Team{
- Name: teamName,
- Description: "desc team1",
- }
- var createTeamResp teamResponse
- s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
- require.NotZero(t, createTeamResp.Team.ID)
- team = createTeamResp.Team
- for _, h := range listHostsRes.Hosts {
- if h.HardwareSerial == devices[1].SerialNumber {
- err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID})
- require.NoError(t, err)
- }
- }
-
- // modify the response and trigger another sync to include:
- //
- // 1. A repeated device with "added"
- // 2. A repeated device with "modified"
- // 3. A device with "deleted"
- // 4. A new device
- deletedSerial := devices[2].SerialNumber
- addedSerial := uuid.New().String()
- devices = []godep.Device{
- {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added"},
- {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini", OS: "osx", OpType: "modified"},
- {SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted"},
- {SerialNumber: addedSerial, Model: "MacBook Mini", OS: "osx", OpType: "added"},
- }
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
-
- // all hosts should be returned from the hosts endpoint
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
- // all previous devices + the manually added host + the new `addedSerial`
- wantSerials = append(wantSerials, devices[3].SerialNumber, nonDEPHost.HardwareSerial)
- require.Len(t, listHostsRes.Hosts, len(wantSerials))
- gotSerials = []string{}
- var deletedHostID uint
- var addedHostID uint
- var mdmDeviceID uint
- for _, device := range listHostsRes.Hosts {
- gotSerials = append(gotSerials, device.HardwareSerial)
- switch device.HardwareSerial {
- case deletedSerial:
- deletedHostID = device.ID
- case addedSerial:
- addedHostID = device.ID
- case mdmDevice.SerialNumber:
- mdmDeviceID = device.ID
- }
- }
- require.ElementsMatch(t, wantSerials, gotSerials)
- require.Len(t, profileAssignmentReqs, 3)
-
- // first request to get a list of profiles
- // TODO: seems like we're doing this request on each loop?
- require.Len(t, profileAssignmentReqs[0].Devices, 1)
- require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // profileAssignmentReqs[1] and [2] can be in any order
- ix2Devices, ix1Device := 1, 2
- if len(profileAssignmentReqs[1].Devices) == 1 {
- ix2Devices, ix1Device = ix1Device, ix2Devices
- }
-
- // - existing device with "added"
- // - new device with "added"
- require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs)
- require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices)
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // - existing device with "modified" and a different team (thus different profile request)
- require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1)
- require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // entries for all hosts except for the one with OpType = "deleted"
- assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID)
- require.NoError(t, err)
- require.NotZero(t, assignment.DeletedAt)
-
- _, err = s.ds.GetHostDEPAssignment(ctx, addedHostID)
- require.NoError(t, err)
-
- // send a TokenUpdate command, it shouldn't re-send the post-enrollment commands
- err = mdmDevice.TokenUpdate()
- require.NoError(t, err)
- checkPostEnrollmentCommands(mdmDevice, false)
-
- // enroll the device again, it should get the post-enrollment commands
- err = mdmDevice.Enroll()
- require.NoError(t, err)
- checkPostEnrollmentCommands(mdmDevice, true)
-
- // delete the device from Fleet
- var delResp deleteHostResponse
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmDeviceID), nil, http.StatusOK, &delResp)
-
- // the device comes back as pending
- listHostsRes = listHostsResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", mdmDevice.UUID), nil, http.StatusOK, &listHostsRes)
- require.Len(t, listHostsRes.Hosts, 1)
- require.Equal(t, mdmDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial)
-
- // we assign a DEP profile to the device
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runWorker()
- require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // it should get the post-enrollment commands
- require.NoError(t, mdmDevice.Enroll())
- checkPostEnrollmentCommands(mdmDevice, true)
-
- // delete all MDM info
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, listHostsRes.Hosts[0].ID)
- return err
- })
-
- // it should still get the post-enrollment commands
- require.NoError(t, mdmDevice.Enroll())
- checkPostEnrollmentCommands(mdmDevice, true)
-
- // The user unenrolls from Fleet (e.g. was DEP enrolled but with `is_mdm_removable: true`
- // so the user removes the enrollment profile).
- err = mdmDevice.Checkout()
- require.NoError(t, err)
-
- // Simulate a refetch where we clean up the MDM data since the host is not enrolled anymore
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, mdmDeviceID)
- return err
- })
-
- // Simulate fleetd re-enrolling automatically.
- err = mdmDevice.Enroll()
- require.NoError(t, err)
-
- // The last activity should have `installed_from_dep=true`.
- s.lastActivityMatches(
- "mdm_enrolled",
- fmt.Sprintf(
- `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`,
- mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber,
- ),
- 0,
- )
-
- // enroll a host into Fleet
- eHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
- ID: 1,
- OsqueryHostID: ptr.String("Desktop-ABCQWE"),
- NodeKey: ptr.String("Desktop-ABCQWE"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local", s.T().Name()),
- Platform: "darwin",
- HardwareSerial: uuid.New().String(),
- })
- require.NoError(t, err)
-
- // on team transfer, we don't assign a DEP profile to the device
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runWorker()
- require.Empty(t, profileAssignmentReqs)
-
- // assign the host in ABM
- devices = []godep.Device{
- {SerialNumber: eHost.HardwareSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified"},
- }
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- require.NotEmpty(t, profileAssignmentReqs)
- require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // report MDM info via osquery
- require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, ""))
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
-
- // transfer to "no team", we assign a DEP profile to the device
- profileAssignmentReqs = []profileAssignmentReq{}
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- s.runWorker()
- require.NotEmpty(t, profileAssignmentReqs)
- require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
-
- // transfer to the team back again, we assign a DEP profile to the device again
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runWorker()
- require.NotEmpty(t, profileAssignmentReqs)
- require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0])
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
-
- // transfer to "no team", but simulate a failed profile assignment
- expectAssignProfileResponseFailed = eHost.HardwareSerial
- profileAssignmentReqs = []profileAssignmentReq{}
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
-
- s.runIntegrationsSchedule()
- checkAssignProfileRequests(eHost.HardwareSerial, nil)
- profUUID := profileAssignmentReqs[0].ProfileUUID
- d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
- require.NotZero(t, d.ResponseUpdatedAt)
- failedAt := d.ResponseUpdatedAt
- checkNoJobsPending()
- // list hosts shows dep profile error
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
-
- // run the integrations schedule during the cooldown period
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // no new request during cooldown
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // create a new team
- var tmResp teamResponse
- s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{
- Name: t.Name() + "dummy",
- Description: "desc dummy",
- }, http.StatusOK, &tmResp)
- require.NotZero(t, createTeamResp.Team.ID)
- dummyTeam := tmResp.Team
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
-
- // expect no assign profile request during cooldown
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // screened for cooldown
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // cooldown hosts are screened from update profile jobs that would assign profiles
- _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial)
- require.NoError(t, err)
- checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // screened for cooldown
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // cooldown hosts are screened from delete profile jobs that would assign profiles
- _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial)
- require.NoError(t, err)
- checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0)
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // screened for cooldown
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // // TODO: Restore this test when FIXME on DeleteTeam is addressed
- // s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK)
- // checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0)
- // s.runIntegrationsSchedule()
- // require.Empty(t, profileAssignmentReqs) // screened for cooldown
- // bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed)
- // d, ok = bySerial[eHost.HardwareSerial]
- // require.True(t, ok)
- // require.Equal(t, failedAt, d.ResponseUpdatedAt)
- // require.Zero(t, d.RetryJobID) // cooling down so no retry job
- // checkNoJobsPending()
-
- // transfer back to no team, expect no assign profile request during cooldown
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK)
- checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // screened for cooldown
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // simulate expired cooldown
- failedAt = failedAt.Add(-2 * time.Hour)
- setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
- d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
- require.NotZero(t, d.RetryJobID) // retry job created
- jobID := d.RetryJobID
- checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
-
- // running the DEP schedule should not trigger a profile assignment request when the retry job is pending
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change
- checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID)
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true)
-
- // run the inregration schedule and expect success
- expectAssignProfileResponseFailed = ""
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- checkAssignProfileRequests(eHost.HardwareSerial, &profUUID)
- d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
- require.True(t, d.ResponseUpdatedAt.After(failedAt))
- succeededAt := d.ResponseUpdatedAt
- checkNoJobsPending()
- checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false)
-
- // run the integrations schedule and expect no changes
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs)
- checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // ingest new device via DEP but the profile assignment fails
- serial := uuid.NewString()
- devices = []godep.Device{
- {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
- }
- expectAssignProfileResponseFailed = serial
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- checkAssignProfileRequests(serial, nil)
- profUUID = profileAssignmentReqs[0].ProfileUUID
- d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID)
- require.NotZero(t, d.ResponseUpdatedAt)
- failedAt = d.ResponseUpdatedAt
- checkNoJobsPending()
- h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error
-
- // transfer to team, no profile assignment request is made during the cooldown period
- profileAssignmentReqs = []profileAssignmentReq{}
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK)
- checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0)
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // screened by cooldown
- checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // run the integrations schedule and expect no changes
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs)
- checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // simulate expired cooldown
- failedAt = failedAt.Add(-2 * time.Hour)
- setAssignProfileResponseUpdatedAt(serial, failedAt)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run
- d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil)
- require.NotZero(t, d.RetryJobID) // retry job created
- jobID = d.RetryJobID
- checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID)
-
- // run the inregration schedule and expect success
- expectAssignProfileResponseFailed = ""
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- checkAssignProfileRequests(serial, nil)
- require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead
- profUUID = profileAssignmentReqs[0].ProfileUUID
- d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared
- require.True(t, d.ResponseUpdatedAt.After(failedAt))
- checkNoJobsPending()
- // list hosts shows pending (because MDM detail query hasn't been reported) but dep profile
- // error has been cleared
- checkListHostDEPError(serial, "Pending", false)
-
- // ingest another device via DEP but the profile assignment is not accessible
- serial = uuid.NewString()
- devices = []godep.Device{
- {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"},
- }
- expectAssignProfileResponseNotAccessible = serial
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase
- expectProfileUUID := ""
- for _, req := range profileAssignmentReqs {
- require.Len(t, req.Devices, 1)
- require.Equal(t, serial, req.Devices[0])
- if expectProfileUUID == "" {
- expectProfileUUID = req.ProfileUUID
- } else {
- require.Equal(t, expectProfileUUID, req.ProfileUUID)
- }
- d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried
- require.NotZero(t, d.ResponseUpdatedAt)
- failedAt = d.ResponseUpdatedAt
- }
- // list hosts shows device pending and no dep profile error for not accessible responses
- checkListHostDEPError(serial, "Pending", false)
-
- // no retry job for not accessible responses even if cooldown expires
- failedAt = failedAt.Add(-2 * time.Hour)
- setAssignProfileResponseUpdatedAt(serial, failedAt)
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runIntegrationsSchedule()
- require.Empty(t, profileAssignmentReqs)
- checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change
- checkNoJobsPending()
-
- // run with devices that already have valid and invalid profiles
- // assigned, we shouldn't re-assign the valid ones.
- devices = []godep.Device{
- {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
- {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
- {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: "bar"}, // doesn't match an existing profile
- {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: "foo"}, // doesn't match an existing profile
- {SerialNumber: addedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
- {SerialNumber: serial, Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile
- }
- expectAssignProfileResponseNotAccessible = ""
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- require.NotEmpty(t, profileAssignmentReqs)
- require.Len(t, profileAssignmentReqs[0].Devices, 2)
- require.ElementsMatch(t, []string{devices[2].SerialNumber, devices[3].SerialNumber}, profileAssignmentReqs[0].Devices)
- checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
-
- // run with only a device that already has the right profile, no errors and no assignments
- devices = []godep.Device{
- {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile
- }
- profileAssignmentReqs = []profileAssignmentReq{}
- s.runDEPSchedule()
- require.Empty(t, profileAssignmentReqs)
-}
-
func loadEnrollmentProfileDEPToken(t *testing.T, ds *mysql.Datastore) string {
var token string
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
@@ -6323,7 +5602,7 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
require.NoError(t, err)
- cases := []struct {
+ endUserAuthCases := []struct {
raw string
expected bool
}{
@@ -6355,6 +5634,73 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
},
}
+ writeTmpJSON := func(t *testing.T, v any) string {
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*.json")
+ require.NoError(t, err)
+ err = json.NewEncoder(tmpFile).Encode(v)
+ require.NoError(t, err)
+ return tmpFile.Name()
+ }
+
+ mustReadFile := func(t *testing.T, path string) string {
+ b, err := os.ReadFile(path)
+ require.NoError(t, err)
+ return string(b)
+ }
+
+ asstOk := writeTmpJSON(t, map[string]any{"ok": true})
+ asstURL := writeTmpJSON(t, map[string]any{"url": "https://example.com"})
+ asstAwait := writeTmpJSON(t, map[string]any{"await_device_configured": true})
+ asstsByName := map[string]string{
+ asstOk: mustReadFile(t, asstOk),
+ asstURL: mustReadFile(t, asstURL),
+ asstAwait: mustReadFile(t, asstAwait),
+ }
+
+ enableReleaseDeviceCases := []struct {
+ enableRelease *bool
+ setupAssistant string
+ expectedRelease bool
+ expectedAssistant string
+ expectedStatus int
+ }{
+ {
+ enableRelease: nil,
+ setupAssistant: "",
+ expectedRelease: false,
+ expectedAssistant: "",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ enableRelease: ptr.Bool(true),
+ setupAssistant: "",
+ expectedRelease: true,
+ expectedAssistant: "",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ enableRelease: ptr.Bool(false),
+ setupAssistant: "",
+ expectedRelease: false,
+ expectedAssistant: "",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ enableRelease: ptr.Bool(false),
+ setupAssistant: asstURL,
+ expectedRelease: false,
+ expectedAssistant: "",
+ expectedStatus: http.StatusUnprocessableEntity,
+ },
+ {
+ enableRelease: ptr.Bool(true),
+ setupAssistant: asstAwait,
+ expectedRelease: false,
+ expectedAssistant: "",
+ expectedStatus: http.StatusUnprocessableEntity,
+ },
+ }
+
t.Run("UpdateAppConfig", func(t *testing.T) {
acResp := appConfigResponse{}
path := "/api/latest/fleet/config"
@@ -6364,11 +5710,13 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
}`, s))
}
- // get the initial appconfig; enable end user authentication default is false
+ // get the initial appconfig; enable end user authentication and release
+ // device default is false
s.DoJSON("GET", path, nil, http.StatusOK, &acResp)
require.False(t, acResp.MDM.MacOSSetup.EnableEndUserAuthentication)
+ require.False(t, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
- for i, c := range cases {
+ for i, c := range endUserAuthCases {
t.Run(strconv.Itoa(i), func(t *testing.T) {
acResp = appConfigResponse{}
s.DoJSON("PATCH", path, fmtJSON(c.raw), http.StatusOK, &acResp)
@@ -6379,6 +5727,43 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
require.Equal(t, c.expected, acResp.MDM.MacOSSetup.EnableEndUserAuthentication)
})
}
+
+ for i, c := range enableReleaseDeviceCases {
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ macSetup := map[string]any{}
+ if c.enableRelease != nil {
+ macSetup["enable_release_device_manually"] = *c.enableRelease
+ }
+ if c.setupAssistant != "" {
+ macSetup["macos_setup_assistant"] = c.setupAssistant
+ }
+
+ uploadSucceeded := true
+ if c.setupAssistant != "" {
+ s.Do("POST", "/api/v1/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
+ Name: c.setupAssistant,
+ EnrollmentProfile: json.RawMessage(asstsByName[c.setupAssistant]),
+ }, c.expectedStatus)
+ if c.expectedStatus >= 300 {
+ uploadSucceeded = false
+ }
+ }
+
+ if uploadSucceeded {
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", path,
+ json.RawMessage(jsonMustMarshal(t, map[string]any{"mdm": map[string]any{"macos_setup": macSetup}})),
+ c.expectedStatus, &acResp)
+ require.Equal(t, c.expectedRelease, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+ require.Equal(t, c.expectedAssistant, acResp.MDM.MacOSSetup.MacOSSetupAssistant.Value)
+ }
+
+ acResp = appConfigResponse{}
+ s.DoJSON("GET", path, nil, http.StatusOK, &acResp)
+ require.Equal(t, c.expectedRelease, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+ require.Equal(t, c.expectedAssistant, acResp.MDM.MacOSSetup.MacOSSetupAssistant.Value)
+ })
+ }
})
t.Run("UpdateTeamConfig", func(t *testing.T) {
@@ -6388,12 +5773,14 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
%s
}`
- // get the initial team config; enable end user authentication default is false
+ // get the initial team config; enable end user authentication and release
+ // device default is false
teamResp := teamResponse{}
s.DoJSON("GET", path, nil, http.StatusOK, &teamResp)
require.False(t, teamResp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication)
+ require.False(t, teamResp.Team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
- for i, c := range cases {
+ for i, c := range endUserAuthCases {
t.Run(strconv.Itoa(i), func(t *testing.T) {
teamResp = teamResponse{}
s.DoJSON("PATCH", path, json.RawMessage(fmt.Sprintf(fmtJSON, tm.Name, c.raw)), http.StatusOK, &teamResp)
@@ -6404,6 +5791,54 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() {
require.Equal(t, c.expected, teamResp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication)
})
}
+
+ for i, c := range enableReleaseDeviceCases {
+ expectedPatchStatus := c.expectedStatus
+ if expectedPatchStatus == http.StatusOK {
+ expectedPatchStatus = http.StatusNoContent
+ }
+
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ if c.setupAssistant != "" {
+ s.Do("POST", "/api/v1/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
+ TeamID: &tm.ID,
+ Name: c.setupAssistant,
+ EnrollmentProfile: json.RawMessage(asstsByName[c.setupAssistant]),
+ }, c.expectedStatus)
+ uploadSucceeded := c.expectedStatus < 300
+
+ if uploadSucceeded {
+ // use the apply team specs to set both the setup assistant and the
+ // enable release at once
+ macSetup := fleet.MacOSSetup{
+ MacOSSetupAssistant: optjson.SetString(c.setupAssistant),
+ }
+ if c.enableRelease != nil {
+ macSetup.EnableReleaseDeviceManually = optjson.SetBool(*c.enableRelease)
+ }
+ teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
+ Name: tm.Name,
+ MDM: fleet.TeamSpecMDM{MacOSSetup: macSetup},
+ }}}
+ s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
+ }
+ } else {
+ // no setup assistant, use the PATCH /setup_experience endpoint
+ payload := map[string]any{
+ "team_id": tm.ID,
+ }
+ if c.enableRelease != nil {
+ payload["enable_release_device_manually"] = *c.enableRelease
+ }
+ s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), expectedPatchStatus)
+ }
+
+ teamResp = teamResponse{}
+ s.DoJSON("GET", path, nil, http.StatusOK, &teamResp)
+ require.Equal(t, c.expectedRelease, teamResp.Team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+ require.Equal(t, c.expectedAssistant, teamResp.Team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value)
+ })
+ }
})
t.Run("TestMDMAppleSetupEndpoint", func(t *testing.T) {
@@ -6675,7 +6110,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() {
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, `The automatic enrollment profile can’t include url.`)
+ require.Contains(t, errMsg, `The automatic enrollment profile can't include url.`)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID)
@@ -6984,10 +6419,21 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() {
//
s.setTokenForTest(t, "gitops1-mdm@example.com", test.GoodPassword)
- // Attempt to edit global MDM settings, should allow.
+ // Attempt to edit global MDM settings, should allow (also ensure the IdP settings are cleared).
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "enable_disk_encryption": true }
+ "mdm": {
+ "macos_setup": {
+ "enable_end_user_authentication": false
+ },
+ "enable_disk_encryption": true,
+ "end_user_authentication": {
+ "entity_id": "",
+ "issuer_uri": "",
+ "idp_name": "",
+ "metadata_url": ""
+ }
+ }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
@@ -10487,7 +9933,7 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
func (s *integrationMDMTestSuite) runWorker() {
err := s.worker.ProcessJobs(context.Background())
require.NoError(s.T(), err)
- pending, err := s.ds.GetQueuedJobs(context.Background(), 1)
+ pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{})
require.NoError(s.T(), err)
require.Empty(s.T(), pending)
}
diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go
index 0939d69ab9..01624d85d0 100644
--- a/server/worker/apple_mdm.go
+++ b/server/worker/apple_mdm.go
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
+ "time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -25,6 +27,7 @@ type AppleMDMTask string
const (
AppleMDMPostDEPEnrollmentTask AppleMDMTask = "post_dep_enrollment"
AppleMDMPostManualEnrollmentTask AppleMDMTask = "post_manual_enrollment"
+ AppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device"
)
// AppleMDM is the job processor for the apple_mdm job.
@@ -41,10 +44,11 @@ func (a *AppleMDM) Name() string {
// appleMDMArgs is the payload for the Apple MDM job.
type appleMDMArgs struct {
- Task AppleMDMTask `json:"task"`
- HostUUID string `json:"host_uuid"`
- TeamID *uint `json:"team_id,omitempty"`
- EnrollReference string `json:"enroll_reference,omitempty"`
+ Task AppleMDMTask `json:"task"`
+ HostUUID string `json:"host_uuid"`
+ TeamID *uint `json:"team_id,omitempty"`
+ EnrollReference string `json:"enroll_reference,omitempty"`
+ EnrollmentCommands []string `json:"enrollment_commands,omitempty"`
}
// Run executes the apple_mdm job.
@@ -64,16 +68,22 @@ func (a *AppleMDM) Run(ctx context.Context, argsJSON json.RawMessage) error {
case AppleMDMPostDEPEnrollmentTask:
err := a.runPostDEPEnrollment(ctx, args)
return ctxerr.Wrap(ctx, err, "running post Apple DEP enrollment task")
+
case AppleMDMPostManualEnrollmentTask:
err := a.runPostManualEnrollment(ctx, args)
return ctxerr.Wrap(ctx, err, "running post Apple manual enrollment task")
+
+ case AppleMDMPostDEPReleaseDeviceTask:
+ err := a.runPostDEPReleaseDevice(ctx, args)
+ return ctxerr.Wrap(ctx, err, "running post Apple DEP release device task")
+
default:
return ctxerr.Errorf(ctx, "unknown task: %v", args.Task)
}
}
func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArgs) error {
- if err := a.installFleetd(ctx, args.HostUUID); err != nil {
+ if _, err := a.installFleetd(ctx, args.HostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
}
@@ -81,13 +91,21 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg
}
func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) error {
- if err := a.installFleetd(ctx, args.HostUUID); err != nil {
+ var awaitCmdUUIDs []string
+
+ fleetdCmdUUID, err := a.installFleetd(ctx, args.HostUUID)
+ if err != nil {
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
}
+ awaitCmdUUIDs = append(awaitCmdUUIDs, fleetdCmdUUID)
- if err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID); err != nil {
+ bootstrapCmdUUID, 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 != "" {
a.Log.Log("info", "got an enroll_reference", "host_uuid", args.HostUUID, "ref", ref)
@@ -112,30 +130,143 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs)
if ssoEnabled {
a.Log.Log("info", "setting username and fullname", "host_uuid", args.HostUUID)
+ cmdUUID := uuid.New().String()
if err := a.Commander.AccountConfiguration(
ctx,
[]string{args.HostUUID},
- uuid.New().String(),
+ cmdUUID,
acct.Fullname,
acct.Username,
); 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.TeamID, args.EnrollReference, awaitCmdUUIDs...); err != nil {
+ return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job")
+ }
+ }
+
return nil
}
-func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) error {
+func (a *AppleMDM) runPostDEPReleaseDevice(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?
+ // - if some commands/profiles failed (a final state), should we go ahead
+ // and release?
+ // - if the device keeps moving team, or profiles keep being added/removed
+ // from its team, it's possible that its profiles will never settle and
+ // always have pending statuses. Same as going offline, should we release
+ // after a while?
+ //
+ // We opted "yes" to all those, and we want to release after a few minutes,
+ // not hours, so we'll allow only a couple retries.
+
+ level.Debug(a.Log).Log(
+ "task", "runPostDEPReleaseDevice",
+ "msg", fmt.Sprintf("awaiting commands %v and profiles to settle for host %s", args.EnrollmentCommands, args.HostUUID),
+ )
+
+ if retryNum, _ := ctx.Value(retryNumberCtxKey).(int); retryNum > 2 {
+ // give up and release the device
+ a.Log.Log("info", "releasing device after too many attempts", "host_uuid", args.HostUUID, "retries", retryNum)
+ if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil {
+ return ctxerr.Wrapf(ctx, err, "failed to enqueue DeviceConfigured command after %d retries", retryNum)
+ }
+ return nil
+ }
+
+ for _, cmdUUID := range args.EnrollmentCommands {
+ if cmdUUID == "" {
+ continue
+ }
+
+ res, err := a.Datastore.GetMDMAppleCommandResults(ctx, cmdUUID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "failed to get MDM command results")
+ }
+
+ var completed bool
+ for _, r := range res {
+ // succeeded or failed, it is done (final state)
+ if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError ||
+ r.Status == fleet.MDMAppleStatusCommandFormatError {
+ completed = true
+ break
+ }
+ }
+
+ if !completed {
+ // DEP enrollment commands are not done being delivered to that device,
+ // cannot release it now.
+ return fmt.Errorf("device not ready for release, still awaiting result for command %s, will retry", cmdUUID)
+ }
+ level.Debug(a.Log).Log(
+ "task", "runPostDEPReleaseDevice",
+ "msg", fmt.Sprintf("command %s has completed", cmdUUID),
+ )
+ }
+
+ // all DEP-enrollment commands are done, check the host's profiles
+ profs, err := a.Datastore.GetHostMDMAppleProfiles(ctx, args.HostUUID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "failed to get host MDM profiles")
+ }
+ for _, prof := range profs {
+ // if it has any pending profiles, then its profiles are not done being
+ // delivered (installed or removed).
+ if prof.Status == nil || *prof.Status == fleet.MDMDeliveryPending {
+ return fmt.Errorf("device not ready for release, profile %s is still pending, will retry", prof.Identifier)
+ }
+ level.Debug(a.Log).Log(
+ "task", "runPostDEPReleaseDevice",
+ "msg", fmt.Sprintf("profile %s has been deployed", prof.Identifier),
+ )
+ }
+
+ // release the device
+ a.Log.Log("info", "releasing device, all DEP enrollment commands and profiles have completed", "host_uuid", args.HostUUID)
+ if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil {
+ return ctxerr.Wrap(ctx, err, "failed to enqueue DeviceConfigured command")
+ }
+ return nil
+}
+
+func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) (string, error) {
cmdUUID := uuid.New().String()
if err := a.Commander.InstallEnterpriseApplication(ctx, []string{hostUUID}, cmdUUID, apple_mdm.FleetdPublicManifestURL); err != nil {
- return err
+ return "", err
}
a.Log.Log("info", "sent command to install fleetd", "host_uuid", hostUUID)
- return nil
+ return cmdUUID, nil
}
-func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) error {
+func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) (string, error) {
// GetMDMAppleBootstrapPackageMeta expects team id 0 for no team
var tmID uint
if teamID != nil {
@@ -146,34 +277,34 @@ func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string,
var nfe fleet.NotFoundError
if errors.As(err, &nfe) {
a.Log.Log("info", "unable to find a bootstrap package for DEP enrolled device, skipping installation", "host_uuid", hostUUID)
- return nil
+ return "", nil
}
- return err
+ return "", err
}
appCfg, err := a.Datastore.AppConfig(ctx)
if err != nil {
- return err
+ return "", err
}
url, err := meta.URL(appCfg.ServerSettings.ServerURL)
if err != nil {
- return err
+ return "", err
}
manifest := appmanifest.NewFromSha(meta.Sha256, url)
cmdUUID := uuid.New().String()
err = a.Commander.InstallEnterpriseApplicationWithEmbeddedManifest(ctx, []string{hostUUID}, cmdUUID, manifest)
if err != nil {
- return err
+ return "", err
}
err = a.Datastore.RecordHostBootstrapPackage(ctx, cmdUUID, hostUUID)
if err != nil {
- return err
+ return "", err
}
a.Log.Log("info", "sent command to install bootstrap package", "host_uuid", hostUUID)
- return nil
+ return cmdUUID, nil
}
// QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to
@@ -186,6 +317,7 @@ func QueueAppleMDMJob(
hostUUID string,
teamID *uint,
enrollReference string,
+ enrollmentCommandUUIDs ...string,
) error {
attrs := []interface{}{
"enabled", "true",
@@ -196,15 +328,25 @@ func QueueAppleMDMJob(
if teamID != nil {
attrs = append(attrs, "team_id", *teamID)
}
+ if len(enrollmentCommandUUIDs) > 0 {
+ attrs = append(attrs, "enrollment_commands", enrollmentCommandUUIDs)
+ }
level.Info(logger).Log(attrs...)
args := &appleMDMArgs{
- Task: task,
- HostUUID: hostUUID,
- TeamID: teamID,
- EnrollReference: enrollReference,
+ Task: task,
+ HostUUID: hostUUID,
+ TeamID: teamID,
+ EnrollReference: enrollReference,
+ EnrollmentCommands: enrollmentCommandUUIDs,
}
- job, err := QueueJob(ctx, ds, appleMDMJobName, args)
+
+ // 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)
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 12a339d6b9..822d67dfd8 100644
--- a/server/worker/apple_mdm_test.go
+++ b/server/worker/apple_mdm_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
@@ -40,6 +41,8 @@ func TestAppleMDM(t *testing.T) {
// specific internals (sequence and number of calls, etc.). The MDM storage
// and pusher are mocks.
ds := mysql.CreateMySQLDS(t)
+ // call TruncateTables immediately as a DB migation may have created jobs
+ mysql.TruncateTables(t, ds)
mdmStorage, err := ds.NewMDMAppleMDMStorage([]byte("test"), []byte("test"))
require.NoError(t, err)
@@ -92,6 +95,32 @@ func TestAppleMDM(t *testing.T) {
return commands
}
+ enableManualRelease := func(t *testing.T, teamID *uint) {
+ if teamID == nil {
+ enableAppCfg := func(enable bool) {
+ ac, err := ds.AppConfig(ctx)
+ require.NoError(t, err)
+ ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(enable)
+ err = ds.SaveAppConfig(ctx, ac)
+ require.NoError(t, err)
+ }
+
+ enableAppCfg(true)
+ t.Cleanup(func() { enableAppCfg(false) })
+ } else {
+ enableTm := func(enable bool) {
+ tm, err := ds.Team(ctx, *teamID)
+ require.NoError(t, err)
+ tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(enable)
+ _, err = ds.SaveTeam(ctx, tm)
+ require.NoError(t, err)
+ }
+
+ enableTm(true)
+ t.Cleanup(func() { enableTm(false) })
+ }
+ }
+
t.Run("no-op with nil commander", func(t *testing.T) {
defer mysql.TruncateTables(t, ds)
@@ -115,7 +144,7 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
require.Empty(t, jobs)
})
@@ -143,7 +172,7 @@ func TestAppleMDM(t *testing.T) {
// ensure the job's not_before allows it to be returned
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Contains(t, jobs[0].Error, "unknown task: no-such-task")
@@ -175,9 +204,49 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ 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
+
+ require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
+ })
+
+ t.Run("installs default manifest, manual release", func(t *testing.T) {
+ t.Cleanup(func() { mysql.TruncateTables(t, ds) })
+
+ h := createEnrolledHost(t, 1, nil, true)
+ enableManualRelease(t, nil)
+
+ mdmWorker := &AppleMDM{
+ Datastore: ds,
+ Log: nopLog,
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ }
+ w := NewWorker(ds, nopLog)
+ w.Register(mdmWorker)
+
+ err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, nil, "")
+ require.NoError(t, err)
+
+ // run the worker, should succeed
+ err = w.ProcessJobs(ctx)
+ require.NoError(t, err)
+
+ // ensure the job's not_before allows it to be returned if it were to run
+ // again
+ time.Sleep(time.Second)
+
+ 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)
+
+ // there is no post-DEP release device job pending
require.Empty(t, jobs)
+
require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
})
@@ -213,9 +282,15 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
- require.Empty(t, jobs)
+
+ // 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
+
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID)
@@ -258,9 +333,64 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ 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
+
+ require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
+
+ ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID)
+ require.NoError(t, err)
+ require.Equal(t, "custom-team-bootstrap", ms.BootstrapPackageName)
+ })
+
+ t.Run("installs custom bootstrap manifest of a team, manual release", func(t *testing.T) {
+ t.Cleanup(func() { mysql.TruncateTables(t, ds) })
+
+ tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"})
+ require.NoError(t, err)
+ enableManualRelease(t, &tm.ID)
+
+ h := createEnrolledHost(t, 1, &tm.ID, true)
+ err = ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{
+ Name: "custom-team-bootstrap",
+ TeamID: tm.ID,
+ Bytes: []byte("test"),
+ Sha256: []byte("test"),
+ Token: "token",
+ })
+ require.NoError(t, err)
+
+ mdmWorker := &AppleMDM{
+ Datastore: ds,
+ Log: nopLog,
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ }
+ w := NewWorker(ds, nopLog)
+ w.Register(mdmWorker)
+
+ err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, &tm.ID, "")
+ require.NoError(t, err)
+
+ // run the worker, should succeed
+ err = w.ProcessJobs(ctx)
+ require.NoError(t, err)
+
+ // ensure the job's not_before allows it to be returned if it were to run
+ // again
+ time.Sleep(time.Second)
+
+ 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)
+
+ // there is no post-DEP release device job pending
require.Empty(t, jobs)
+
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID)
@@ -292,7 +422,7 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Contains(t, jobs[0].Error, "MDMIdPAccount with uuid abcd was not found")
@@ -334,9 +464,15 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
- require.Empty(t, jobs)
+
+ // 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
+
// confirm that AccountConfiguration command was not enqueued
require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
})
@@ -383,9 +519,15 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
- require.Empty(t, jobs)
+
+ // 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
+
require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "AccountConfiguration"}, getEnqueuedCommandTypes(t))
})
@@ -413,7 +555,7 @@ func TestAppleMDM(t *testing.T) {
// again
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 1)
+ jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
require.Empty(t, jobs)
require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t))
diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go
index e2633363bf..0a9d20bf1f 100644
--- a/server/worker/macos_setup_assistant_test.go
+++ b/server/worker/macos_setup_assistant_test.go
@@ -25,6 +25,8 @@ import (
func TestMacosSetupAssistant(t *testing.T) {
ctx := context.Background()
ds := mysql.CreateMySQLDS(t)
+ // call TruncateTables immediately as some DB migrations may create jobs
+ mysql.TruncateTables(t, ds)
// create a couple hosts for no team, team 1 and team 2 (none for team 3)
hosts := make([]*fleet.Host, 6)
@@ -140,7 +142,7 @@ func TestMacosSetupAssistant(t *testing.T) {
err = w.ProcessJobs(ctx)
require.NoError(t, err)
// no remaining jobs to process
- pending, err := ds.GetQueuedJobs(ctx, 10)
+ pending, err := ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Empty(t, pending)
}
diff --git a/server/worker/worker.go b/server/worker/worker.go
index ec10c5e52d..b670a67029 100644
--- a/server/worker/worker.go
+++ b/server/worker/worker.go
@@ -12,11 +12,17 @@ import (
"github.com/go-kit/kit/log/level"
)
+type ctxKey int
+
const (
maxRetries = 5
// nvdCVEURL is the base link to a CVE on the NVD website, only the CVE code
// needs to be appended to make it a valid link.
nvdCVEURL = "https://nvd.nist.gov/vuln/detail/"
+
+ // context key for the retry number of a job, made available via the context
+ // to the job processor.
+ retryNumberCtxKey = ctxKey(0)
)
const (
@@ -89,14 +95,26 @@ func (w *Worker) Register(jobs ...Job) {
// identified by the name (e.g. "jira"). The args value is marshaled as JSON
// and provided to the job processor when the job is executed.
func QueueJob(ctx context.Context, ds fleet.Datastore, name string, args interface{}) (*fleet.Job, error) {
+ return QueueJobWithDelay(ctx, ds, name, args, 0)
+}
+
+// QueueJobWithDelay is like QueueJob but does not make the job available
+// before a specified delay (or no delay if delay is <= 0).
+func QueueJobWithDelay(ctx context.Context, ds fleet.Datastore, name string, args interface{}, delay time.Duration) (*fleet.Job, error) {
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshal args")
}
+
+ var notBefore time.Time
+ if delay > 0 {
+ notBefore = time.Now().UTC().Add(delay)
+ }
job := &fleet.Job{
- Name: name,
- Args: (*json.RawMessage)(&argsJSON),
- State: fleet.JobStateQueued,
+ Name: name,
+ Args: (*json.RawMessage)(&argsJSON),
+ State: fleet.JobStateQueued,
+ NotBefore: notBefore,
}
return ds.NewJob(ctx, job)
@@ -122,7 +140,7 @@ func (w *Worker) ProcessJobs(ctx context.Context) error {
// process jobs until there are none left or the context is cancelled
seen := make(map[uint]struct{})
for {
- jobs, err := w.ds.GetQueuedJobs(ctx, maxNumJobs)
+ jobs, err := w.ds.GetQueuedJobs(ctx, maxNumJobs, time.Time{})
if err != nil {
return ctxerr.Wrap(ctx, err, "get queued jobs")
}
@@ -191,6 +209,7 @@ func (w *Worker) processJob(ctx context.Context, job *fleet.Job) error {
args = *job.Args
}
+ ctx = context.WithValue(ctx, retryNumberCtxKey, job.Retries)
return j.Run(ctx, args)
}
diff --git a/server/worker/worker_test.go b/server/worker/worker_test.go
index 093c587f7c..4e0446bff5 100644
--- a/server/worker/worker_test.go
+++ b/server/worker/worker_test.go
@@ -35,7 +35,7 @@ func TestWorker(t *testing.T) {
// set up mocks
getQueuedJobsCalled := 0
- ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) {
+ ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) {
if getQueuedJobsCalled > 0 {
return nil, nil
}
@@ -93,7 +93,7 @@ func TestWorkerRetries(t *testing.T) {
State: fleet.JobStateQueued,
Retries: 0,
}
- ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) {
+ ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) {
if theJob.State == fleet.JobStateQueued {
return []*fleet.Job{theJob}, nil
}
@@ -173,7 +173,7 @@ func TestWorkerMiddleJobFails(t *testing.T) {
Retries: 0,
},
}
- ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) {
+ ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) {
var queued []*fleet.Job
for _, j := range jobs {
if j.State == fleet.JobStateQueued {
@@ -241,6 +241,8 @@ func TestWorkerMiddleJobFails(t *testing.T) {
func TestWorkerWithRealDatastore(t *testing.T) {
ctx := context.Background()
ds := mysql.CreateMySQLDS(t)
+ // call TruncateTables immediately, because a DB migration may create jobs
+ mysql.TruncateTables(t, ds)
oldDelayPerRetry := delayPerRetry
delayPerRetry = []time.Duration{
@@ -295,7 +297,7 @@ func TestWorkerWithRealDatastore(t *testing.T) {
// timestamp in mysql vs the one set in ProcessJobs (time.Now().Add(...)).
time.Sleep(time.Second)
- jobs, err := ds.GetQueuedJobs(ctx, 10)
+ jobs, err := ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, j2.ID, jobs[0].ID)
@@ -311,7 +313,7 @@ func TestWorkerWithRealDatastore(t *testing.T) {
// timestamp in mysql vs the one set in ProcessJobs (time.Now().Add(...)).
time.Sleep(time.Second)
- jobs, err = ds.GetQueuedJobs(ctx, 10)
+ jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, j2.ID, jobs[0].ID)
@@ -326,7 +328,7 @@ func TestWorkerWithRealDatastore(t *testing.T) {
time.Sleep(time.Second)
- jobs, err = ds.GetQueuedJobs(ctx, 10)
+ jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{})
require.NoError(t, err)
require.Empty(t, jobs)
diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt
index 9a03bf15b6..7fc10249d5 100644
--- a/tools/cloner-check/generated_files/appconfig.txt
+++ b/tools/cloner-check/generated_files/appconfig.txt
@@ -113,6 +113,10 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSetup fleet.MacOSSetup
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually optjson.Bool
+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/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
@@ -121,9 +125,6 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM EndUserAuthentication fleet.MDMEndU
github.com/fleetdm/fleet/v4/server/fleet/MDMEndUserAuthentication SSOProviderSettings fleet.SSOProviderSettings
github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsEnabledAndConfigured bool
github.com/fleetdm/fleet/v4/server/fleet/MDM EnableDiskEncryption optjson.Bool
-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/MDM 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/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt
index b140ae9841..fcf77c0fca 100644
--- a/tools/cloner-check/generated_files/teammdm.txt
+++ b/tools/cloner-check/generated_files/teammdm.txt
@@ -20,6 +20,10 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSetup fleet.MacOSSetup
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String
+github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually optjson.Bool
+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/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