diff --git a/changes/17401-add-enable-release-device-manually b/changes/17401-add-enable-release-device-manually
index 8ad195e74..4fcda2283 100644
--- a/changes/17401-add-enable-release-device-manually
+++ b/changes/17401-add-enable-release-device-manually
@@ -1 +1,2 @@
* Added the `enable_release_device_manually` configuration setting for a team and no team. **Note** that the macOS automatic enrollment profile cannot set the `await_device_configured` option anymore, this setting is controlled by Fleet via the new `enable_release_device_manually` option.
+* Automatically release a macOS DEP-enrolled device after enrollment commands and profiles have been delivered, unless `enable_release_device_manually` is set to `true`.
diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go
index 3835edc47..aaa150741 100644
--- a/ee/server/service/mdm.go
+++ b/ee/server/service/mdm.go
@@ -228,7 +228,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl
return err
}
- var didUpdate, didUpdateMacOSEndUserAuth bool
+ var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool
if payload.EnableEndUserAuthentication != nil {
if ac.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication {
ac.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication
@@ -241,6 +241,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl
if ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually {
ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually)
didUpdate = true
+ didUpdateMacOSReleaseDevice = true
}
}
@@ -248,6 +249,11 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
return err
}
+ if didUpdateMacOSReleaseDevice {
+ if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, nil, nil); err != nil {
+ return err
+ }
+ }
if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, ac.MDM.MacOSSetup.EnableEndUserAuthentication, nil, nil); err != nil {
return err
@@ -257,6 +263,13 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl
return nil
}
+func (svc *Service) updateMacOSSetupEnableReleaseDevice(ctx context.Context, enable bool, teamID *uint, teamName *string) error {
+ if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil {
+ return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job")
+ }
+ return nil
+}
+
func (svc *Service) updateMacOSSetupEnableEndUserAuth(ctx context.Context, enable bool, teamID *uint, teamName *string) error {
if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil {
return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job")
diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go
index 4f9ab22e9..bf5b29f02 100644
--- a/ee/server/service/teams.go
+++ b/ee/server/service/teams.go
@@ -1136,6 +1136,11 @@ func (svc *Service) editTeamFromSpec(
}
}
+ if didUpdateEnableReleaseManually {
+ if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &team.ID, &team.Name); err != nil {
+ return err
+ }
+ }
if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil {
return err
@@ -1241,7 +1246,7 @@ func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.T
}
func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSetupPayload) error {
- var didUpdate, didUpdateMacOSEndUserAuth bool
+ var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool
if payload.EnableEndUserAuthentication != nil {
if tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication {
tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication
@@ -1254,6 +1259,7 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team,
if tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually {
tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually)
didUpdate = true
+ didUpdateMacOSReleaseDevice = true
}
}
@@ -1261,6 +1267,11 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team,
if _, err := svc.ds.SaveTeam(ctx, tm); err != nil {
return err
}
+ if didUpdateMacOSReleaseDevice {
+ if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &tm.ID, &tm.Name); err != nil {
+ return err
+ }
+ }
if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication, &tm.ID, &tm.Name); err != nil {
return err
diff --git a/server/datastore/mysql/jobs.go b/server/datastore/mysql/jobs.go
index 3c68ec45b..d674ad26c 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 177d54f22..400003f57 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 000000000..3d7db95b6
--- /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 000000000..5ec92d352
--- /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 5b23f10ca..7db678014 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -556,8 +556,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` (
@@ -779,9 +780,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=257 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=258 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');
+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,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/fleet/datastore.go b/server/fleet/datastore.go
index 4081db8af..29be3baee 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -844,8 +844,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/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go
index b358238dc..0e9f79982 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 4485074a4..a03b5030d 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 280596a0c..24f8def56 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 d8f7149ad..267b1448c 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 146982697..bab3ff194 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -580,7 +580,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)
@@ -4089,11 +4089,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 d0454b027..701dbb087 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -630,7 +630,9 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
mdmSSOSettingsChanged := oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings !=
appConfig.MDM.EndUserAuthentication.SSOProviderSettings
serverURLChanged := oldAppConfig.ServerSettings.ServerURL != appConfig.ServerSettings.ServerURL
- if (mdmEnableEndUserAuthChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() {
+ mdmEnableReleaseDeviceChanged := oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value !=
+ appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
+ if (mdmEnableEndUserAuthChanged || mdmEnableReleaseDeviceChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() {
if err := svc.EnterpriseOverrides.MDMAppleSyncDEPProfiles(ctx); err != nil {
return nil, ctxerr.Wrap(ctx, err, "sync DEP profiles")
}
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
new file mode 100644
index 000000000..4e83e3c0f
--- /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 7d0e43310..39d322cba 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 {
@@ -7140,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)
@@ -10643,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 0939d69ab..01624d85d 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 12a339d6b..822d67dfd 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 e2633363b..0a9d20bf1 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 ec10c5e52..b670a6702 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 093c587f7..4e0446bff 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)